본문 바로가기
독서/스프링 부트 핵심 가이드

[스프링 부트 핵심 가이드] Chapter 13장. 서비스의 인증과 권한 부여

by JayAlex07 2023. 10. 14.

스프링 부트 핵심 가이드

img

[스프링 부트 핵심 가이드] Chapter 13장. 서비스의 인증과 권한 부여

 

어플리케이션을 개발하다 보면, 유저들이 인증과 인가를 해야 사용할 수 있는 기능들을 개발하게 된다

 

즉 보안 기능을 추가 해야 한다는 것이고, 보안을 위해서는 스프링에서 Spring Security가 있다

  • 어플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나
  • 보안 관련된 많은 기능을 제공한다

 

 

보안 용어 이해

 

인증 (Authentication)

  • 사용자가 누구인지 확인하는 단계다 (로그인)
  • 확인이 되면, 서버는 유저에게 토큰을 전달한다
  • 이 토큰을 이용하여, 유저는 원하는 리소스에 접근할 수 있게 된다

인가 (Authorization)

  • 인증된 토큰, 즉 검증된 유저가 어플리케이션 내부의 리소스에 접근할 때 유저가 해당 리소스에 접근할 권리가 있는지 확인하는 과정이다
    • 예) 일반 유저는, 관리 권한을 갖지 못 하여, 관리 리소스에 접근할 수 없다
  • 인증 단계에서 발급 받은 토큰은 인가 내용을 포함하고 있다

 

접근 주체 (principal)

  • 어플리케이션을 사용하는 주체다
  • 접근 주체는 사용자, 디바이스, 시스템이 될 수 있다
  • 어플리케이션은 인증 과정을 통해 접근 주체가 신뢰할 수 있는지 확인 후, 인가 과정을 통해 접근 주체에게 부여된 권한을 확인하는 과정을 거친다

 

 

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

 

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

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

  • 필터 체인 (FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationsFilterChain을 의미한다
  • 클라이언트에서 요청을 보내면 서블릿 컨테이너 (ApplicationsFilterChain)은 URI를 확인해서 필터와 서블릿을 매핑한다

  • 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다
  • 이는 서블릿 컨테이너의 생명주기와 스프링 어플리케이션 컨텍스트 (Application Context) 사이에서 다리 역할을 수행하는 필터 구현체다
  • 표준 서블릿 필터를 구현하고 있고, 역할을 위임할 필터체인 프록시 (FilterChainProxy, 위에서는 Bean Filter)를 내부에 가지고 있다
  • 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성된다
    • 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인 (SecurityFilterChain)을 통해 많은 보안 필터를 사용할 수 있다
    • 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있다
  • 보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정할 수 있다
    • 여러 보안 필터체인을 만들기 위해서는 위 클래스를 상속 받아야 한다
    • WebSecurityConfiguererAdapter 클래스에는 @Order 어노테이션을 통해 우선순위를 지정 되어 있고 2개 이상 클래스를 생성했을 때에는 @Order 어노테이션을 사용하여 우선순위를 정하는 것이 중요하다

 

  1. 클라이언트로부터 Http Request, 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 맡겨지고, 위에 그림 같은 경우 UsernamePasswordAuthenticationToken에서 인증처리를 한다
  2. AuthenticationFilter는 요청 객체 (HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성한다
  3. 생성한 토큰을 AuthenticationManager에 전달을 한다. AuthenticationManager는 인터페이스고, 일반적으로 사용되는 구현체는 ProviderManager이다
  4. ProviderManager는 인증을 위해 AuthenticationProvider(s)로 토큰을 전달한다
  5. AuthenticationProvider(s)는 토큰의 정보를 UserDetailsService에 전달한다
  6. UserDetailsService는 전달받은 정보를 통해 DB에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다
  7. 생성된 UserDetails 객체는 AuthenticationProvider로 다시 전달된다
  8. 해당 AuthenticationProvider에서 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달한다
  9. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다
  10. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 전달한다

 

 

 

JWT (JSON Web Token)

 

정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다

  • URL로 이용할 수 있는 문자열로만 구성돼 있다
  • 디지털 서명이 적용되어 있어 신뢰할 수 있다
  • JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용되고, 문자열로만 구성되어 있다

 

아래는 JWT의 구조다

  • Header (헤더)
    • 검증과 관련된 내용을 담고 있다
    • 해싱 알고리즘과, 토큰의 타입을 지정한다
{
    "alg" : "HS256",
    "typ" : "JWT"
}
  • Payload (내용)
    • 토큰에 담긴 정보를 포함한다
    • 이곳에 포함된 속성들은 클레임 (Claim)이라고 하며, 세가지로 분류된다
      • 등록된 클레임 (Registered Claims)
      • 공개 클레임 (Public Claims) : 키 값을 마음대로 정의할 수 있다
      • 비공개 클레임 (Private Claims) : 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미한다
    • 등록된 클레임
      • iss : JWT의 발급자 (Issuer) 주체를 나타낸다. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열이다
      • sub : JWT의 제목 (Subject)이다
      • aud : JWT의 수신인 (Audience)이다. JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 한다. 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부된다
      • exp : JWT의 만료시간 (Expiration)이다. 시간은 NumericDate 형식으로 지정해야 한다
      • nbf : 'Not Before'를 의미한다
      • iat : JWT가 발급된 시간 (Issued at) 이다
      • jti : JWT의 식별자 (JWT ID) 이다. 주로 중복 처리를 방지하기 위해 사용된다
{
    "sub" : "example",
    "exp" : "231321321321",
    "userId" : "hello",
    "username" : "hi"
}
  • Signature (서명)
    • JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생선된다
HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
)

 

 

스프링 시큐리티와 JWT 적용

 

의존성 추가

# pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

 

UserDetails와 UserDetailsService 구현

  • User 엔티티를 만든다
// entity/User.java

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    @Column(nullable=false, unique=true)
    private String uid;

    @JsonProperty(access = Access.WRITE_ONLY)
    @Column(nullable = false, length = 100)
    private String password;

    @Column(nullable = false)
    private String name;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return role.stream()
                .map(SimpleGrantedAuthority::new) // 스프링 security에서 제공하는 role 관련된 authority를 사용
                .collect(Collectors.toList());
    }

    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

UserDetails 인터페이스의 메서드

  • getAuthorities() : 계정이 가지고 있는 권한 목록을 리턴한다
  • getPassword() : 계정의 비밀번호를 리턴한다
  • getUsername() : 계정의 이름을 리턴한다. 일반적으로는 아이디를 리턴
  • isAccountNonExpired() : 계정이 만료됐는지 리턴한다. true는 만료되지 않았다는 의미다
  • isAccountNonLocked() : 계정이 잠겨있는지 리턴한다. true는 잠기지 않았다는 의미다
  • isCredentialsNonExpired() : 비밀번호가 만료됐는지 리턴한다. true는 만료되지 않았다는 의미다
  • isEnabled() : 계정이 활성화되어 있는지 리턴한다. true는 활성화 상태를 의미한다

 

 

UserRepository 구현

  • 기존에 레포지토리를 작성하던 방법과 동일하다
public interface UserRepository extends JpaRepository<User, Long> {
    User getByUid(String uid);
}

 

 

UserDetailsServiceImpl 구현

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

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

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
        return userRepository.getByUid(username).
            orElseThrow(() -> new RuntimeException("유저를 찾을 수 없음"));
    }
}

 

 

JwtTokenProvider 구현

  • JWT 토큰을 생성하는 데 필요한 정보를 UserDetails에서 가져올 수 있다
  • JWT 토큰을 생성하는 JwtTokenProvider를 생성해야 한다
@Component
@RequiredArgsConstructor
public class TokenProvider {

    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsServiceImpl userDetailsServiceImpl;

    private static final String KEY_ROLES = "roles";
    private static final long VALIDATE_TIME = 1 * 60 * 60 * 1000L; // 1시간

    @Value("${spring.jwt.secret}")
    private String secretKey;

    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

    // 토큰 생성 매서드
    public String generateToken(String username, List<String> roles) {

        LOGGER.info("[generateToken] 토큰 생성 시작");

        Claims claims = Jwts.claims().setSubject(username);
        claims.put(KEY_ROLES, roles); // key value로 저장

        var now = new Date();
        var expiredDate = new Date(now.getTime() + VALIDATE_TIME);

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now) // 토큰 생성시간
                .setExpiration(expiredDate) // 토큰 만료 시간
                .signWith(SignatureAlgorithm.HS512, this.secretKey) // 사용할 암호화 알고리즘, 비밀키
                .compact();

        LOGGER.info("[generateToken] 토큰 생성 완료");

        return token;
    }

    public Authentication getAuthentication(String jwt) {

        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");

        UserDetails userDetails = memberServiceImpl.loadUserByUsername(getUsername(jwt));

        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}"
                    , userDetails.getUsername());

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

    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");

        // 토큰이 유효한지 확인
        String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().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("[validateToken] 토큰 유효 체크 시작");

        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);

            //토큰 만료 시간이 현재보다 이전인지 아닌지 만료 여부 확인
            return !claims.getBody().getExpiration().before(new Date());

            // 파싱하는 과정에서 토큰 만료 시간이 지날 수 있다, 만료된 토큰을 확인할 때에
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false; // 토큰이 빈 문자열이면 false
        }
    }
}
  • secretKey가 필요함으로 application.properitesspring.jwt.secret={secret 키} 를 추가한다
    • 위에 경우 인코딩을 init() 에서 하지만, 따로 인코딩을 해서 application.properties에 저장해 놔도 된다
    • 위에 init() 은 JwtTokenProvider에 @Component 어노테이션이 지정되어 있고, init()에는 @PostConstruct가 지정되어 있어, 어플리케이션이 가동되면 자동으로 init()이 실행된다
  • getAuthentication()
    • 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할이다
    • UsernamePasswordAuthenticationToken을 사용하는 것이 편하다
      • UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있고, AbstractAuthenticationToken은 Authentication 인터페이스의 구현체다
      • 이를 사용하기 위해서는 UserDetails가 필요하고, 이 객체는 UserDetailsService를 통해 가지고 온다
  • resolveToken()
    • HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴한다

 

 

JwtAuthenticationFilter 구현

  • JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스다
// OncePerRequestFilter 한 요청 당 한번 필터가 실행이 된다
// 요청이 들어올 때마다 토큰이 있는지 없는지 확인

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

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

    private final TokenProvider tokenProvider;

    public static final String TOKEN_HEADER = "Authorization";

    //인증 타입
    public static final String TOKEN_PREFIX = "Bearer ";

    public JwtAuthenticationFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request, 
        HttpServletResponse response, 
        FilterChain filterChain) throws ServletException, IOException {

        String token = getTokenFromRequest(request);

        LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);

        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");

        if (token != null && tokenProvider.validateToken(token)) {

            // 토큰 유효성 검증
            Authentication authentication = tokenProvider.getAuthentication(token);

            SecurityContextHolder.getContext().setAuthentication(authentication);

            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }

        filterChain. doFilter(request, response);
    }
}
  • 대표적으로 많이 사용되는 상속 객체는 GenericFilterBean과 OncePerRequestFilter이다
    • GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스다
    • 다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장해두고 동일한 클라이언트의 요청을 받으면 재활요하는 구조여서 GenericFilterBean을 상속 받으면 RequestDispatcher에 의해 다른 서블릿으로 디스패치가 되면서 필터가 두 번 실행되는 현상이 발생할 수 있다
    • 이 문제를 해결하기 위해 등장한 것이 OncePerRequestFilter이다

 

 

SecurityConfiguration 구현

  • 스프링 시큐리티와 관련된 설정이다
  • 대표적으로 WebSecurityConfigurerAdapter를 상속받는 Configuration 클래스를 구현한다
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 어노테이션으로 권한 부여
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenProvider tokenProvider;

    @Autowired
    public SecurityConfiguration(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable()
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .authorizeRequests()
                    // 토큰 없이 접근 가능
                .antMatchers("/sign-api/sign-in", "/sign-api/sign-up", "/sign-api/exception").permitAll()
                    //  /product로 시작하는 경로의 GET 요청은 모두 허용
                .antMatchers(HttpMethod.GET, "/product/**").permitAll()
                    // exception 단어가 들어간 경로는 모두 허용
                   .antMatchers("**exception**").permitAll()
                    // 기타 요청은 인증된 권한을 가진 사용자에게 허용
                .anyRequest().hasRole("ADMIN")
            .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
            .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
            .and()
                .addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
                                UsernamePasswordAuthenticationFilter.class);

    }

    @Override
    public void configure(final WebSecurity web) throws Exception {
        // 주로 개발 관련
        webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
                                          "/swagger-ui/**", "/webjars/**", "/swagger/**",
                                          "/sign-api/exception");
    }

}
  • 스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행한다
    • 리소스 접근 권한 설정
    • 인증 실패 시 발생하는 예외 처리
    • 인증 로직 커스터마이징
    • csrf, cors 등의 스프링 시큐리티 설정
  • httpBasic().disable()
    • UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화한다
  • csrf().disable()
    • REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화 하는 로직이다
    • CSRF는 Cross-Site Request Forgery의 줄임말로 '사이트 간 요청 위조'를 의미한다
    • csrf() 메서드는 기본적으로 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰을 검증하는 방식으로 동작한다
    • 브라우저 사용 환경이 아니면 비활성화를 해도 된다
  • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    • REST API 기반 어플리케이션의 동작 방식을 설정한다
    • 진행 중인 프로젝트에서는 JWT 토큰으로 인증을 처리하며, 세션을 사용하지 않기 때문에 STATELESS로 설정한다
  • authorizeRequest()
    • 어플리케이션에 들어오는 요청에 대한 사용 권한을 체크한다
    • antMatchers() 메서드는 antPatter을 통해 권한을 설정하는 역할이다
  • exceptionHandling().accessDeniedHandler()
    • 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달한다
  • exceptionHandling().authenticationEntryPoint()
    • 인증 과정에서 예외가 발생할 경우 예외를 전달한다

각 두 exceptionHandling은 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달한다

 

 

커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

  • 인증과 인가 과정의 예외 상황에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달한다
  • CustomAccessDeniedHandler
    • 엑세스 권한이 없는 리소스에 접근할 경우 발생하는 에외
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

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

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDniedException exception) throws IOException {

        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}
  • CustomAuthenticationEntryPoint
    • commence() 메서드는 예외 처리를 위해 리다이렉트가 아니라 직접 Response를 생성해서 클라이언트에게 응답을 한다
    • 메세지를 담기 위해 EntryPointErrorResponse 객체를 사용해 메세지를 설정한다
    • response에 상태 코드와 콘텐츠 타입 등을 설정한 후 ObjectMapper를 사용해 EntryPointErrorResponse 객체를 바디 값으로 파싱한
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

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

        ObjectMapper objectMapper = new ObjectMapper();

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

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

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

}
// EntryPointErrorResponse
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
    private String msg;
}

 

 

회원가입

// SignService

public interface SignService {
    SignUpResultDto signUp(String id, String password, String name, String role);

    SignInResultDto signIn(String id, String password) throws RuntimeException;
}
// SignServiceImpl

@Service
public class SignServiceImpl implements SignService {

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

    public UserRepository userRepository;
    public TokenProvider tokenProvider;
    public PasswordEncoder passwordEncoder;

    @Autowired
    public SignServiceImpl(UserRepository userRepository, TokenProvider tokenProvider, PasswordEncoder passwordEncoder){
        this.userRepository = userRepository;
        this.tokenProvider = tokenProvider;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    SignUpResultDto signUp(String id, String password, String name, String role) {
        LOGGER.info("[getSignUpResult] 회원 가입 정보 전달");

        User user;

        if (role.equalsIgnoreCase("admin")) {
            user = User.builder()
                .uid(id)
                .name(name)
                .password(passwordEncoder.encode(password))
                .roles(Collections.singletonList("ROLE_ADMIN"))
                .build();
        } else {
            user = User.builder()
                .uid(id)
                .name(name)
                .password(passwordEncoder.encode(password))
                .roles(Collections.singletonList("ROLE_USER"))
                .build();
        }

        User savedUser = userRepository.save(user);
        SignUpResultDto signUpResultDto = new SignInResultDto();

        LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");

        if(!savedUser.getName().isEmpty()) {
            LOGGER.info("[getSignUpResult] 정상 처리 완료");
            setSuccessResult(signUpResultDto);
        } else {
            LOGGER.info("[getSignUpResult] 실패 처리 완료");
               setFailResult(SignUpResultDto);
        }
        return signUpResultDto;
    }


    @Override
    public SignInResultDto signIn(String id, String password) throws RuntimeException {

        LOGGER.info("[getSignInResult] signDataHandler로 회원 정보 요청");
        User user = userRepository.getByUid(id);
        LOGGER.info("[getSignInResult] Id : {}", id);

        LOGGER.info("[getSignInResult] 패스워드 비교 수행");
        if(!passwordEncoder.matches(password, user.getPassword())) {
            throw new RuntimeException("비밀번호가 일치하지 않습니다");
        }
        LOGGER.info("[getSignInResult] 패스워드 일치");

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

        LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
        setSuccessResult(signInResultDto);

        return signInResultDto;            
    }

    private void setSuccessResult(SignUpResultDto result) {
        result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMsg(CommonResponse.SUCCESS.getMsg());
    }

    private void setFailResult(SignUpResultDto result) {
        result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMsg(CommomResponse.FAIL.getMsg());
    }
}
// PasswordEncoderConfiguration

@Configuration
public class PasswordEncoderConfiguration {

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

 

 

로그인

  • id를 기반으로 UserRepository에서 User 엔티티를 가져온다
  • PasswordEncoder를 사용해 데이터 베이스에 저장되어 있던 비밀번호와 입력받은 비밀번호가 일치하는지 확인
  • 비밀번호가 일치해서 인증을 통과하면 TokenProvider를 통해 id와 role 값을 전달해서 토큰을 생성한 후 Response에 담아 전다
// CommonResponse

package com.springboot.security.common;

@Getter
@Setter
public enum CommonResponse {

    SUCCESS(0, "Success"), FAIL(-1, "FAIL");

    int code;
    String msg;
}
// SignController

@RestController
@RequestMapping("/sign-api")
public class SignController {

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

    @Autowired
    public SignController(SignService signService) {
        this.signService = signService;
    }

    @PostMapping(value = "/sign-in")
    public SignInResultDto signIn(
        @ApiParam(value = "ID", required=true) @RequestParam String id,
        @ApiParam(value = "Password", required=true) @RequestParam String password)
        throws RuntimeException {

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

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

        return signInResultDto;
    }

    @PostMapping(value = "/sign-up")
    public SignUpResultDto signUp(
        @ApiParam(value = "ID", required=true) @RequestParam String id,
        @ApiParam(value = "비밀번호", required=true) @RequestParam String password,
        @ApiParam(value = "이름", required=true) @RequestParam String name,
        @ApiParam(value = "권한", required=true) @RequestParam String role) {

        LOGGER.info("[signUp] 회원가입을 합니다. id : {}, password : ****, name : {}, role : {}",
                   id, name, role);

        SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);
        LOGGER.info("[signUp] 회원가입을 완료했습니다. id : {}" , id);
        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();
        //responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
        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

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {

    private boolean success;

    private int code;

    private String msg;
}
// SignInResultDto

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto {

    private String token;

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