JAVA/Spring Boot

(Spring Security)스프링 환경에서 JWT 토큰 발급

ri5 2022. 3. 21. 19:05

JWT Authentication Flow

HTTP는 connectionless, stateless한 성질을 가지고 있는 프로토콜 입니다. 그렇기에 유저 인증이 필요한 경우 인증이
필요할 때 마다 로그인을 할 수 없으므로 토큰이나 세션을 통해 유저 인증 상태를 관리하게 되는데 그중 가장 널리 사용되는 JWT 토큰 전략을 스프링 시큐리티 활용하여 사용방법과 동작과정을 정리한 글입니다.

인증, 인가 과정

1. 유저 정보를 자바에서 Validation을 활용하여 유저 정보를 체크하고 H2 데이터 베이스에 저장을 합니다.

2. 스프링 시큐리티를 통해 유저 로그인 정보를 인증하고 토큰을 발급 받습니다.

3. 스프링 시큐리티를 통해 전달 받은 토큰을 통해 인가 과정을 거친 후 서비스 로직을 동작하도록 합니다.


스프링 시큐리티가 있는 스프링 부트 서버 아키텍처

스프링 부트에서 스프링 시큐리티의 동작과정

※ api/v1/users/signup 에서 v1을 넣은 이유는 해당 api의 버전을 의미하는 것이니 빼셔도 무방합니다.

  • WebSecurityConfigurerAdapter: 스프링 시큐리티에서 가장 핵심적인 부분으로 HttpSecurity, cors, csrf, 세션 관리 및 리소스 접근 제한 규칙 등을 구성하기 위한 설정을 제공합니다. 이러한 구성요소를 확장 및 커스텀 할 수 있습니다.
  • AuthenticationEntryPoint: 인증 오류를 catch 합니다.
  • OncePerRequestFilter: 각 API 요청에 따라 단일 실행을 보장합니다.
    쉽게 이야기 하자면 일단 Filter는 서블릿이 실행하기 전이나 후에 호출하여 동작합니다.
    요청이 Servlet으로 전달되면 RequestDispatcher는 이를 다른 서블릿으로도 전달할 수도 있습니다.
    만약에 다른 서블릿도 동일한 필터를 거치도록 설정되어 있으면
    해당 시나리오는 중복된 인증과정을 여러번 거치게 되면서 동일한 필터가 여러 번 호출되게 됩니다.
    이러한 중복 처리를 방지하기 위한 FilterOncePerRequestFilter 입니다.
  • UsernamePasswordAuthenticationToken: 로그인 요청시 { 유저 아이디, 유저 패스워드 } 가져와서 AuthenticationManager에 로그인 계정을 인증하는데 사용합니다.
    AuthenticationManager: 전달된 UserPasswordAuthenticationToken 객체를 DaoAuthenticationProvider (UserDetailService & PasswordEncoder를 같이 사용합니다)를 통해 입증하고 완전히 채워진 Athentication 객체를 리턴합니다.
  • UserDetailsService: 해당 인터페이스는 유저의 정보와 UserDeatails Object를 return
    UserDetails: 유저 정보 사용에 필요한 정보를 담고 있습니다. (ex: userID, email, Password 등)
  • AuthContoroller는 유저 로그인 및 회원가입 기능을 핸들링하기 위한 컨트롤러
  • TestController 는 유저 인가가 제대로 동작하는지 테스트 하기위한 컨트롤러

프로젝트 구조

제가 사용한 프로젝트 구조입니다. 저는 따로 테이블을 나뉘어서 관리 하지 않기 때문에 Role 테이블을 만들지 않았습니다. 예제에서 커스텀해서 사용했기 때문에 참조하시는 분은 링크를 통해 따라하시는 것을 추천드립니다.

프로젝트 구조

security: 여기에서 Spring Security를 설정하고 Securiry Object를 구현합니다.

  • WebSecurityConfig: WebSecurityConfigurerAdapter를 상속받은 Configuration
  • UserDeatailServiceImpl: UserDetailService의 구현체
  • AuthEntryPoint: AuthenticationEntryPoint 구현체
  • AuthTokenFilter: OnceperRequestFilter를 상속받은 토큰 인증을 하기위한 필터
  • JwtUtils: JWT를 파싱, 생성, 검증을 하는 방법을 제공하는 클래스

controller: 가입 및 로그인 등의 승인된 요청을 처리하기 위한 레이어

  • UserController: signIn, signup 등
  • TestController: 유저 권한 인증을 테스트 하기 위한 컨트롤러

service: 요청한 작업에 대해 데이터를 처리하거나 응답해주는 로직이 담긴 레이어

  • UserService: 유저 서비스를 추상화시킨 인터페이스
  • UserServiceImpl: 추상화된 유저 서비스 로직을 구현한 구현체

repository: Jpa를 사용하여 실제로 Entity를 통한 동적 쿼리를 만드는 레이어

model: 테이블에 해당하는 Entity를 모아 놓는 레이어


프로젝트 설정

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation'
    compileOnly 'com.h2database:h2'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-test'
    implementation 'org.springframework.security:spring-security-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'mysql:mysql-connector-java'
    implementation 'io.jsonwebtoken:jjwt-api:0.10.7'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.10.7'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.10.7'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.assertj:assertj-core:3.11.1'
}

Spring Application.properties 구성

# h2 console 접근 허용
spring.h2.console.enabled=true
# 재실행시 모든 테이블을 드랍하고 생성
spring.jpa.hibernate.ddl-auto=create-drop
# 디비 주소
spring.datasource.url=jdbc:h2:~/data/testdb
# 디비와 연결할 데이터 베이스 계정
spring.datasource.username=sa
# 디비와 연결할 계정 패스워드
spring.datasource.password=
# jpa sql 조회
spring.jpa.show-sql=true

test.app.jwtSecret = 암호화 시킬 문자(랜덤 문자 150글자 이상)
test.app.jwtExpirationMs = JWT 만료 기간

모델 생성

데이터 베이스에 사용할 모델 생성 (저는 Role을 따로 만들어서 관리 하지 않게 하기 위해서 따로 Entity를 구성하지 않았습니다.)

Erole.java

package com.edube.server.domain.user.model;

public enum ERole {
    ROLE_USER,
    ROLE_ADMIN;
}

User.java

import com.edube.server.domain.common.BaseTimeEntity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.ColumnDefault;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@NoArgsConstructor
@Entity
@Getter
@SuperBuilder
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column
    private String nickName;

    @Column
    private String refreshToken;

    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private ERole role;

    @Column(length = 50)
    @ColumnDefault("'ACTIVE'")
    private String status;

    public void setUserTypeRole(ERole role){
        this.role = role;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}
  • email을 유저 아이디 처럼 사용할 것이며
  • roleEnumerated(EnumType.String)를 선언함으로써 ERole의 이름을 데이터 베이스 값으로 사용할 수 있습니다.
  • refreshToken은 추후에 추가할 refresh 토큰을 사용해서 토큰 탈취에 대한 취약점을 보완하기 위해 미리 추가해 놓습니다.
  • status는 유저의 계정 상태를 관리함으로써 정지 계정 및 활성화 계정을 분류하기 위해 추가하였습니다.
  • rolePasswordsetter는 추후 관리자 계정을 추가할 수 도 있고 패스워드는 해시암호화하여 저장해야 되기 때문에 메서드를 선언해 놓습니다.

common/BaseTimeEntity.java

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class BaseTimeEntity {
    @CreatedDate
    protected LocalDateTime createdAt;
    @LastModifiedDate
    protected LocalDateTime updatedAt;
}

MappedSuperclass:부모 클래스는 테이블과 매핑하지 않고, 부모 클래스를 상속받는 자식 클래스에게 매핑 정보만 제공하고 싶을 때 를 사용
EntityListeners(AuditingEntityListener.class): 도메인마다 추가되는 생성일, 수정일, 식별자 등의 공통된 데이터를 데이터베이스에서 누가, 언제하였는지 기록을 잘 남겨놓아야 합니다. 그래서 JPA에서는 adult라는 기능을 제공하는데 시간에 대해서 자동으로 값을 넣어주는 기능입니다.
SuperBuilder: 부모 객체를 상속받는 자식 객체를 만들 때, 부모 객체의 필드값도 지정할 수 있게 하기 위해서 사용.


Repository

UserRepository.java

import com.edube.server.domain.user.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String nickName);

    Boolean existsByEmail(String nickName);

    Boolean existsByNickName(String nickName);
}

JpaRepository와 상속받아 User EntityRepository를 통해서 메서드 만으로 사용할 수 있게 되었습니다.


Spring Security 구성

WebSecurityConfig.java

WebSecurityConfigurerAdapter를 사용하여 보안 설정 구성.

import com.edube.server.security.jwt.AuthEntryPointJwt;
import com.edube.server.security.jwt.AuthTokenFilter;
import com.edube.server.security.service.UserDetailServiceImpl;
import lombok.RequiredArgsConstructor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailServiceImpl userDetailsService;

    private final AuthEntryPointJwt unauthorizedHandler;;

    @Bean
    public AuthTokenFilter authenticationJwtFilter() {
        return new AuthTokenFilter();
    }
    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception{
        authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                .antMatchers("/api/v1/users/**").permitAll()
                .antMatchers("/error").permitAll()
                .antMatchers("/h2-console/**").permitAll()
                .antMatchers("api/v1/test/**").hasRole("USER")
                .anyRequest().authenticated().and()
                .headers().frameOptions().disable().and()
                .csrf().ignoringAntMatchers("/h2-console/**") .disable();
        http.addFilterBefore(authenticationJwtFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}
  • @EnubleWebSecurity: 스프링이 자동으로 클래스를 찾아 웹 보안 설정을 구성하도록 합니다.
  • @EnubleGlonalMethodSecurity: MethodSecurity는 우리가 기존에 사용했던 SecurityConfig 설정이 적용되지 않는다. 그렇기 때문에 해당 어노테이션을 사용하여 활성화 시킵니다.
  • @RequiredArgsConstructor: 생성자를 통한 의존성 주입을 통해 순환 참조 문제를 컴파일 단계에서 발견할 수 있습니다.
  • exceptionHandling().authenticationEntryPoint(unauthorizedHandler): 권한 인증 관련 문제가 생겼을 때 에러를 핸들링할
    핸들러 입니다.
  • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): 스프링 시큐리티에서 세션정책을 사용하기 위한 설정으로 세션을 따로 사용하지 않고 JWT를 사용할 것 이기 때문에 STATELESS로 설정하여 비활성화 시킵니다.
  • authorizeRequests(): 시큐리티 처리에 HttpServletRequest를 이용한다는 것을 의미합니다.
    • HttpServletRequest: Http프로토콜의 request 정보를 서블릿에게 전달하기 위한 목적으로 사용되며 Header정보, Parameter, Cookie, URI, URL 등의 정보를 읽어들이는 메소드를 가진 클래스
  • antMatchers().permitAll(): 모든 권한을 허용하는 URL입니다. `/`**은 하위에 있는 모든 URL을 포함한다는 의미입니다.
  • antMatchers().hasRole(): 해당 권한을 가진 사용자만 허용하도록 합니다. String 값을 파라미터로 전달받습니다.
  • headers().frameOptions().disable()csrf().ignoringAntMatchers("/h2-console/").disable()**는 H2 Console을 활용하기 위해 설정해두었지만 그만큼 보안적 취약점을 가지게 됩니다.(해당 부분에 대해서는 추후에 다시 공유하도록 하겠습니다.)
  • http.addFilterBefore(authenticationJwtFilter(), UsernamePasswordAuthenticationFilter.class): UsernamePasswordAuthenticationFilter가 동작하기 전에 먼저 동작하게 되는 필터를 설정합니다.

UserDetails & UserDetailsService 구현

Spring Security는 인증 및 인가를 수행하기 위해 사용자 세부사항을 로드합니다. 아래와 같이 사용하게 되는데 Authentication에서 인증이 성공했다면 사용자 정보(UserDetails)를 가져올 수 있게 됩니다.

사용 예제

Authentication authentication = 
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(username, password)
        );
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// userDetails.getUsername()
// userDetails.getPassword()
// userDetails.getAuthorities()

security/service/UserDetailsServiceImpl.java

import com.edube.server.domain.user.model.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;

@Getter
public class UserDetailsImpl implements UserDetails {
    private Long id;
    private String nickName;
    private String email;
    @JsonIgnore
    private String password;
    private String authority;


    public UserDetailsImpl(Long id, String nickName, String email, String password, String authority) {
        this.id = id;
        this.nickName = nickName;
        this.email = email;
        this.password = password;
        this.authority = authority;
    }

    public static UserDetailsImpl build(User user){
        return new UserDetailsImpl(
                user.getUserId(),
                user.getNickName(),
                user.getUserEmail(),
                user.getPassword(),
                user.getRole().name()
        );
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Arrays.asList(new SimpleGrantedAuthority(authority));
    }

    @Override
    public String getUsername() {
        return nickName;
    }

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

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

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

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

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (o == null || getClass() != o.getClass())
            return false;
        UserDetailsImpl user = (UserDetailsImpl) o;
        return Objects.equals(id, user.id);
    }

}

위의 코드를 사용할 유저 정보와 권한 등을 설정하여 사용합니다. 만약 여러 개의 권한을 관리해야된다면 List형태의 Role을 Set형태로 변화하여 사용해야 추후에 스프링 시큐리티에서 Authentication 객체를 사용할 때 문제가 생기지 않습니다.저는 단하나의 Role만 적용할 것이기 때문에 SimpleGrantedAuthority를 사용하여 권한을 부여하였습니다

security/service/UserDetailsSerivceImpl.java

UserDetailsService를 통해 UserDetail 객체를 가져오는데 사용하기 때문에 아래에 있는 인터페이스를 구현하여야 합니다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
@Service
@RequiredArgsConstructor
public class UserDetailServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + email));
        return UserDetailsImpl.build(user);
    }
}
  • UserRepository를 통해 User 객체를 가져오고 사용자가 정의한 UserDetails 객체 형태로 변환합니다.

요청 필터

AuthTokenFilterOncePerRequestFilter를 상속받고 doFilterInternal()을 오버라이드 함으로써 한번의 요청의 단일 실행을 보장하는 필터를 정의할 수 있습니다.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

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


public class AuthTokenFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtils jwtUtils;
    @Autowired
    private  UserDetailServiceImpl userDetailService;

    private static final Logger logger = LoggerFactory.getLogger(AuthTokenFilter.class);

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String jwt = parseJwt(request);
        try{
            if(jwt != null && jwtUtils.validateJwtToken(jwt)) {
                String email = jwtUtils.getUserNameFromJwtToken(jwt);
                UserDetails userDetails = userDetailService.loadUserByUsername(email);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,
                        null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }catch (Exception e) {
            logger.error("Cannot set user authentication: {}", e);
        }
        filterChain.doFilter(request, response);
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");

        if(StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7, headerAuth.length());
        }

        return null;
    }
}
  • doFilterInternal() 내부에서의 동작과정
    • JWT 토큰을 Authorizaion Header에서 가져옵니다.(접두사인 Bearer를 삭제합니다.)
    • 요청한 JWT 토큰을 검증하고 이메일 정보를 파싱합니다.
    • 이메일을 통해 UserDetails 객체를 가져오고 Authentication 객체를 만듭니다.
    • setAuthentication(authentication)메서드를 통해서 UserDetaills 객체는 SecurityContext안에서 사용할 수 있습니다.

예시

UserDetails userDetails =
    (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// userDetails.getUsername()
// userDetails.getPassword()
// userDetails.getAuthorities()

JWT Utils 클래스 생성

JwtUtils는 3가지의 함수를 가지게 됩니다.

  • JWT에 사용될 이메일, 만료일, 시크릿 키 등을 넣습니다.
  • JWT에서 이메일 정보를 추출합니다.
  • JWT를 검증합니다.
@Component
@NoArgsConstructor
public class JwtUtils {
    private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);
    @Value("${edube.app.jwtSecret}")
    private String jwtSecret;
    @Value("${edube.app.jwtExpirationMs}")
    private int jwtExpirationMs;
    public String generateJwtToken(Authentication authentication) {
        UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();
        return Jwts.builder()
                .setSubject((userPrincipal.getEmail()))
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }
    public String getUserNameFromJwtToken(String token) {
        return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
    }
    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
            return true;
        } catch (SignatureException e) {
            logger.error("Invalid JWT signature: {}", e.getMessage());
        } catch (MalformedJwtException e) {
            logger.error("Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            logger.error("JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            logger.error("JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            logger.error("JWT claims string is empty: {}", e.getMessage());
        }
        return false;
    }
}
  • @Value 어노테이션을 통해 application.properties에 설정해놓은 jwtSecret값과 jwtExpirationMs값을 가져옵니다.
  • 각각 시그니처 정보가 잘못되었을 경우, 잘못된 JWT 토큰이 전달될 경우, 토큰 만료일이 지났을 경우, 검증되지 않은 토큰인 경우. JWT 토큰의 클레임이 비어있을 경우를 예외처리하도록 합니다.

Ahthentication Exception Handling

AuthenticationEntryPoint라는 인터페이스를 구현한 AuthEntryPointJwt클래스를 생성합니다. commence()메서드를 오버라이드하여 언제든지 유저가 인증하는 과정에서 문제가 생겼을 때 트리거처럼 동작하여 예외를 처리하여
HTTP resource와 예외처리 정보를 Throw 합니다.

security/jwt/AuthEntryPointJwt.java

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

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

@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
    private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        logger.error("Unauthorized error: {}", authException.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
    }
}

스프링에서서로 전달하고 주고 받을 RequetDto와 Response Dto를 만듭니다.

  • Request 예시
    • LoginRequest: { username, password }
    • SignupRequest: { username, email, password }
  • Response 예시
    • JwtResponse: { token, type, id, username, email, roles }
    • MessageResponse: { message }

자신의 입맛에 맞게 POJO 형태의 DTO를 생성을 합니다.


실제 서비스 로직 구현

UserService.java

일단 추상화를 시켜서 구현할 기능에 대해 인터페이스를 선언하였습니다.​

import com.edube.server.domain.user.model.User;


public interface UserService {
    Long signUp(User user);

    String signIn(User user);

    String hashPassword(String password);
}

UserServiceImpl.java

추상화를 시킨 인터페이스의 실제 로직을 구현한 클래스를 생성합니다.

import com.edube.server.domain.user.model.ERole;
import com.edube.server.domain.user.model.User;
import com.edube.server.domain.user.repository.UserRepository;
import com.edube.server.exception.CustomException;
import com.edube.server.exception.ErrorCode;
import com.edube.server.security.jwt.JwtUtils;
import lombok.RequiredArgsConstructor;
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.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;


@Service
@RequiredArgsConstructor
@Transactional
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    private final AuthenticationManager authenticationManager;

    private final JwtUtils jwtUtils;

    private final PasswordEncoder passwordEncoder;

    /*private final AuthenticationManager authenticationManager;*/
    @Override
    public Long signUp(User user){
        if (userRepository.existsByNickName(user.getEmail())) {
            throw new CustomException(ErrorCode.DUPLICATED_EMAIL);
        }
        if(userRepository.existsByNickName(user.getNickName())) {
            throw new CustomException(ErrorCode.DUPLICATED_NICKNAME);
        }

        user.setUserTypeRole(ERole.ROLE_USER);

        user.setPassword(hashPassword(user.getPassword()));

        return userRepository.save(user).getUserId();
    }

    @Override
    public String signIn(User user){
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String jwt = jwtUtils.generateJwtToken(authentication);


        return jwt;
    }

    @Override
    public String hashPassword(String password) {
        return passwordEncoder.encode(password);
    }
}

굳이 해시화 하는 것을 나눌 필요는 없지만 좀 더 책임을 분리시키기 위해서 저는 분리를 시켰습니다.


컨트롤러의 구현과 테스트

UserContorlller.java

실제 url이 매핑되어 해당 요청을 핸들링하여 응답하는 컨트롤러를 생성합니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
    private final UserService userService;

    @PostMapping("/signup")
    public ResponseEntity<String> signUp(@RequestBody UserSignUpRequestDto requestDto){
        userService.signUp(requestDto.toEntity());
        return ResponseEntity.ok("User signUp successfully!");
    }

    @PostMapping("/signin")
    public ResponseEntity<String> signIn(@RequestBody UserSignInRequestDto requestDto) {
        return ResponseEntity.ok(userService.signIn(requestDto.toEntity()));
    }
}
  • @RequestMapping 어노테이션을 활용하여 해당 컨트롤러가 어떠한 도메인을 핸들링하는지 명시해줍니다.
  • 굳이 Dto를 활용하여 toEntity()를 사용하여 전달하는 이유는 좀 더 loose coupling하게 구성하여 의존성을 줄이기 위함입니다.

TestController.java

실제로 토큰을 활용해볼 컨트롤러입니다.

@RestController
@RequestMapping("/api/v1/test")
public class TestController {
    @GetMapping("/user")
    public String userAccess() {
        return "User Content.";
    }
}

회원 가입

로그인

인가 테스트


후기

스프링 MVC에서 사용되어지는 부분이 스프링 시큐리티에도 많이 사용되어서 미리 스프링 MVC를 공부하고 스프링 시큐리티를 적용했다면 좀 더 쉽게 접근할 수 있었을 것 같습니다.

이번에 스프링 시큐리티를 적용하고 정리해보면서 스프링에 몰랐던 부분을 많이 알게되었고 제가 원하는 대로 커스텀하고 삽질해보면서 구현해보니 좀 더 구체적인 플로우에 대해 알 수 있었습니다.

외국사이트에 있는 Example을 제가 원하는 구조로 커스텀 하다보니 부족한 부분이 있을 수 있습니다. 해당 게시글에 부족하거나 잘못된 개념에 대해 피드백을 주신다면 감사하겠습니다.

출처

https://www.bezkoder.com/spring-boot-jwt-auth-mongodb/

www.bezkoder.com](https://www.bezkoder.com/spring-boot-jwt-auth-mongodb/)

https://www.baeldung.com/spring-onceperrequestfilter