Java/인증 및 인가, IAM

[Java] Spring Boot Security 3.x + JWT 이해하기 -2 : 환경설정 및 구성

adjh54 2024. 10. 6. 16:38
728x170
Spring Boot Security 3.x 환경에서 JWT를 이용한 로그인 과정에 대해 환경설정 및 구성 방법에 대해 알아봅니다.

 
 
 

💡 [참고] Spring Security 관련 글 및 Github Repository 경로입니다. 참고하시면 도움이 됩니다.
분류 상세 분류 주제 링크
Spring Boot 2.x 이론 Spring Boot Security 이해하기 -1 : 2.7.x 버전 구조 및 파일 이해 https://adjh54.tistory.com/91
Spring Boot 2.x 환경 설정 Spring Boot Security 이해하기 -2 : 2.7.x 버전 구현하기 https://adjh54.tistory.com/92
Spring Boot 2.x 이론 Spring Boot Security 이해하기 -3: JWT(JSON Web Token) 이해하기 https://adjh54.tistory.com/93
Spring Boot 2.x 환경 설정 Spring Boot Security 이해하기 -4: JWT 환경 설정 및 구성 하기 https://adjh54.tistory.com/94
Spring Boot 2.x 소스코드 Spring Boot Security 2.x + JWT 기반 구성 Github Repository https://github.com/adjh54ir/blog-codes/tree/main/spring-boot2-security
       
기타 이론 Spring Boot 2.x.x 버전 프로젝트 생성: 지원 종료 및 다운그레이드 https://adjh54.tistory.com/361
       
Spring Boot 3.x 이론 Spring Boot Security 3.x + JWT 이해하기 -1 : 구조 및 Client / Server 처리과정 https://adjh54.tistory.com/576
Spring Boot 3.x 환경 설정 Spring Boot Security 3.x + JWT 이해하기 -2 : 환경 설정 및 구성 https://adjh54.tistory.com/577
Spring Boot 3.x 환경 설정 Spring Boot Security 3.x + JWT 이해하기 -3 : Refresh Token 활용 https://adjh54.tistory.com/583
Spring Boot 3.x 환경 설정 Spring Boot Security 3.x + JWT 이해하기 -4 : 로그아웃 + 블랙리스트 활용 https://adjh54.tistory.com/592
Spring Boot 3.x 소스코드 Spring Boot Security 3.x + JWT 기반 구성 Github Repository https://github.com/adjh54ir/blog-codes/tree/main/spring-boot3-security
React 소스코드 Spring Boot 3.x와 통신하여 로그인을 수행하는 클라이언트 구성 Github Repository https://github.com/adjh54ir/blog-codes/tree/main/react-login

 

1) 구성 패키지 구조 및 설명


💡 구성 패키지 구조 및 설명

- Spring Boot Security 3.4.3 버전 + JWT 0.12.6 버전을 활용하여 구성한 프로젝트의 패키지 구조 및 설명입니다.

패키지 파일 명 설명
common.utils TokenUtils - JWT의 구성요소를 생성하고 최종적으로 JWT를 생성하여 유효성을 체크하는 유틸입니다.
config DataSourceConfig - 데이터베이스와 연결 및 SQL Mapper(MyBatis)와의 연결을 담당하는 설정 클래스입니다.
config WebSecurityConfig - Spring Security 환경 설정을 구성하기 위한 설정 클래스입니다.
config.filter CustomAuthenticationFilter - 아이디와 비밀번호 기반의 데이터를 Form 데이터로 전송을 받아 '인증'을 담당하는 필터 역할의 클래스입니다.
config.filter JwtAuthorizationFilter - 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인하는 필터 역할의 클래스입니다.
config.handler CustomAuthenticationProvider - 전달받은 사용자의 아이디와 비밀번호를 기반으로 비즈니스 로직을 처리하여 사용자의 ‘인증’에 대해서 검증을 수행하는 클래스입니다.
config.handler CustomAuthFailureHandler - 사용자의 '인증'에 대해 실패하였을떄, 수행하여 사용자에게 응답 값을 제공해주는 Handler입니다.
config.handler CustomAuthSuccessHandler - 사용자의 '인증'에 대해 성공하였을때, 수행하여 사용자에게 사용자 정보 및 JWT에 대한 응답 값을 제공해주는 Handler입니다.
controller TokenController - 토큰 생성 테스트를 위해 임시로 구성한 Controller입니다.
controller UserController - 로그인 테스트를 위해 임시로 구성한 Controller입니다.
mapper UserMapper - 사용자 관련 SQL Mapper 인터페이스 입니다.
model.dto UserDetailsDto - Spring Security에서 사용되는 UserDetailsDto 클래스 입니다.
model.dto UserDto - tb_user 테이블과 매핑되는 DTO 클래스입니다.
service UserService - 사용자 서비스 인터페이스입니다.
service.impl UserDetailsServiceImpl - 사용자 인증 정보를 로드하고 UserDetails 객체를 생성하는 역할을 담당
service.impl UserServiceImpl - 사용자 서비스의 구현체 클래스입니다.

 

 

 

2) 구성 개발환경


개발환경 분류 버전 설명
java jdk java version 17 Java 개발 키트
spring-boot spring boot starter 3.3.4 Spring Boot 프레임워크
spring-boot-web spring boot starter 3.3.4 Spring Boot 웹 애플리케이션 개발 지원
spring-boot-security spring boot starter 3.3.4 Spring Security 통합
org.mybatis.spring.boot:mybatis-spring-boot-starter spring boot starter 3.0.3 MyBatis와 Spring Boot 통합
io.jsonwebtoken:jjwt opensource 0.12.6 JSON Web Token 생성 및 검증
com.fasterxml.jackson.core:jackson-databind opensource 2.16.1 JSON 데이터 바인딩 라이브러리
jakarta.xml.bind:jakarta.xml.bind-api opensource 4.0.2 XML 바인딩 API
org.postgresql:postgresql opensource - PostgreSQL JDBC 드라이버
lombok spring boot - Java 코드 생성 라이브러리

 

💡 [참고] Spring Security 6.3.3 기준 (* Spring Boot 3.x 기준) Java 17 이상을 사용해야 합니다.

https://docs.spring.io/spring-security/reference/prerequisites.html

 
 

3) 개발환경 구성 : 테이블 구성 및 데이터베이스 연결


💡 개발환경 구성 : 테이블 구성 및 데이터베이스 연결

- 로그인 기능을 구현하기 위해서 데이터베이스와의 연결 설정을 구성합니다.

1. 테이블 구조 : tb_user


컬럼 설명
user_sq 사용자 시퀀스
user_id 사용자 아이디
user_pw 사용자 패스워드
user_nm 사용자 이름
user_st 사용자 상태 (S: 일반, D: 탈퇴)
create table tb_user
(
    user_sq serial
        constraint tb_user_pk
            primary key,
    user_id text,
    user_pw text,
    user_nm text,
    user_st text
);

alter table tb_user
    owner to localmaster;

 
 

2. 라이브러리 설정


💡 라이브러리 설정

- 해당 데이터베이스 연결을 위해서 사용하는 Mybatis Framework와 postgreSQL 데이터베이스의 드라이버를 설치해 줍니다.
dependencies {
	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
	runtimeOnly 'org.postgresql:postgresql'
}

 
 

3. 패키지 구조


💡 패키지 구조

- 해당 개발 환경에서는 SQL Mapper인 MyBatis Framework를 통해서 환경을 구성합니다.

파일 설명
application.properties - yml 파일을 통해서 환경설정을 구성하기 위해 서버 실행 시 application-loc.yml 파일을 바라보게 구성합니다
application-loc.yml - 로컬 데이터베이스 연결을 위해서 환경 설정을 구성합니다
DataSourceConfig.java - 로컬 데이터베이스와 MyBatis 연결을 위해 환경 클래스를 구성합니다
config/common-mybatis-config.xml - MyBatis에서 사용하는 설정 정보를 지정합니다.
sql/ddl.sql - 임시로 사용자 테이블을 만들 수 있는 스크립트입니다.

 

4. application.properties


 💡 application.properties

- spring.profiles.active 속성을 loc로 지정함으로써 application-loc.yml 파일을 바라보도록 지정하였습니다.
spring.application.name=spring-boot3-security
spring.profiles.active=loc

 

💡[참고] 개발환경에 따라 각각 환경을 구성하는 방법에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.
 

[Java] 개발 환경에 따라 각각 환경 파일 구성 방법: application.properties

해당 글에서는 Spring Boot 기반 로컬, QA, 운영 환경에서 각각 다른 환경파일을 사용하는 방법에 대해서 공유합니다. 1) 개발환경 분류 이름 버전 언어 Java 11 프레임워크 Spring Boot 2.7.12 프레임워크 Sp

adjh54.tistory.com

 

5. application-loc.yml


💡 application-loc.yml

- yaml 파일 형태로 spring boot의 datasource를 지정합니다.
- 기본적으로 HikariCP를 이용하며, PostgreSQL 데이터베이스와의 연결을 구성하였습니다.
# Spring Boot Configuration
spring:
  # 1. Spring Boot JDBC + HikariCP 설정
  datasource:
    hikari:
      driver-class-name: org.postgresql.Driver
      jdbc-url: jdbc:postgresql://localhost:5432/testdb
      username: localmaster
      password: qwer1234
      pool-name: Hikari Connection Pool  # Alias
      maximum-pool-size: 5

 
 

6. DataSourceConfig.java


💡 DataSourceConfig.java

- DataSource에 대한 설정을 위한 설정 파일입니다.
- 해당 클래스의 주요한 역할은 MyBatis와 데이터베이스를 연결하는 역할을 수행합니다.
어노테이션/메서드 설명
@Configuration 이 클래스가 Spring의 구성 클래스임을 나타냅니다
@PropertySource application.properties 파일을 속성 소스로 사용함을 지정합니다
hikariConfig() HikariCP 설정을 위한 빈을 생성합니다. spring.datasource.hikari 접두사를 가진 속성들을 사용합니다
dataSource() HikariDataSource를 사용하여 DataSource 빈을 생성합니다
sqlSessionFactory() MyBatis SqlSessionFactory를 설정합니다. 매퍼 위치, 타입 별칭 패키지, MyBatis 설정 파일 등을 지정합니다
sqlSessionTemplate() SqlSessionTemplate 빈을 생성합니다. 이는 MyBatis 작업을 위한 템플릿입니다
package com.adjh.springboot3security.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import javax.sql.DataSource;

/**
 * 데이터베이스와 연결 및 SQL Mapper(MyBatis)와의 연결을 담당하는 설정 클래스입니다.
 *
 * @author : jonghoon
 * @fileName : DataSourceConfig
 * @since : 10/1/24
 */
@Configuration
@PropertySource("classpath:/application.properties")
public class DataSourceConfig {
    final ApplicationContext applicationContext;

    public DataSourceConfig(ApplicationContext ac) {
        this.applicationContext = ac;
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public HikariConfig hikariConfig() {
        return new HikariConfig();
    }

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource(hikariConfig());
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean session = new SqlSessionFactoryBean();
        session.setDataSource(dataSource);
        session.setMapperLocations(applicationContext.getResources("classpath:mapper/*.xml"));
        session.setTypeAliasesPackage("com.adjh.springboot3security.model.dto");
        session.setConfigLocation(new PathMatchingResourcePatternResolver().getResource("classpath:config/common-mybatis-config.xml"));
        return session.getObject();
    }

    @Bean
    public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

 
 

7. common-mybatis-config.xml


💡 common-mybatis-config.xml

- MyBatis의 설정 파일로, SQL 매퍼의 동작을 세부적으로 제어하는 데 사용됩니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">

<!--
    해당 파일은 Mybatis 관련 환경 설정을 하는 파일입니다.
-->

<configuration>
    <settings>
        <!-- column name to camel case-->
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <!-- 쿼리 결과 필드가 null인 경우, 누락이 되서 나오지 않게 설정-->
        <setting name="callSettersOnNulls" value="true"/>
        <!-- 쿼리에 보내는 파라미터가 null인 경우, 오류가 발생하는 것 방지 -->
        <setting name="jdbcTypeForNull" value="NULL"/>
    </settings>
</configuration>

 

 

8. ddl.sql


💡 ddl.sql

- tb_user 테이블을 생성하기 위한 DDL SQL 스크립트입니다.
create table tb_user
(
    user_sq serial
        constraint tb_user_pk
            primary key,
    user_id text,
    user_pw text,
    user_nm text,
    user_st text
);

alter table tb_user
    owner to localmaster;

 


 

9. 중간 결과 확인


💡 중간 결과 확인

- 데이터베이스 연결이 완료되어 서버가 정상적으로 수행되었습니다.

 
 

 

 

4) 개발환경 : 소스코드 구성


 

1. 라이브러리 설정 : build.gradle


💡 라이브러리 설정 : build.gradle

- 이전에 데이터베이스 설정을 위한 라이브러리 설정에 아래의 라이브러리를 설치해 줍니다.

 

dependencies {
	implementation "org.springframework.boot:spring-boot-starter-security:3.3.4"
	implementation "org.springframework.boot:spring-boot-starter-web:3.3.4"
	implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.1'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
}

 
 

2. UserDto


 💡 UserDto

- 클라이언트로부터 전달받은 사용자 아이디(userId), 패스워드(userPw)를 정의하며, 추가로 데이터베이스에서 조회되는 데이터를 조회해 올 때 사용이 됩니다.
package com.adjh.springboot3security.model.dto;

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

/**
 * tb_user 테이블과 매핑되는 DTO 클래스입니다.
 *
 * @author : jonghoon
 * @fileName : UserDto
 * @since : 10/1/24
 */
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserDto {

    // 사용자 시퀀스
    private int userSq;

    // 사용자 아이디
    private String userId;

    // 사용자 패스워드
    private String userPw;

    // 사용자 이름
    private String userNm;

    // 사용자 상태
    private String userSt = "S";


    @Builder(toBuilder = true)
    private UserDto(int userSq, String userId, String userPw, String userNm, String userSt) {
        this.userSq = userSq;
        this.userId = userId;
        this.userPw = userPw;
        this.userNm = userNm;
        this.userSt = userSt;
    }

}

 
 

3. UserDetailDto


💡 UserDetailDto

- Spring Security에서 사용되는 UserDetails 인터페이스를 정의한 DTO 클래스입니다.
package com.adjh.springboot3security.model.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.Delegate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

/**
 * Spring Security에서 사용되는 UserDetails 인터페이스를 정의한 DTO 클래스입니다.
 *
 * @author : jonghoon
 * @fileName : UserDetailsDto
 * @since : 10/1/24
 */
@Slf4j
@Getter
@AllArgsConstructor
public class UserDetailsDto implements UserDetails {


    // @Delegate 어노테이션을 사용하여 UserDto 객체의 메서드를 이 클래스에서 직접 사용할 수 있게 합니다.
    @Delegate
    private UserDto userDto;

    private Collection<? extends GrantedAuthority> authorities;

    /**
     * 사용자의 권한 목록을 반환합니다.
     *
     * @return Collection<? extends GrantedAuthority>
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    /**
     * 사용자의 비밀번호를 반환합니다.
     *
     * @return String
     */
    @Override
    public String getPassword() {
        return userDto.getUserPw();
    }


    /**
     * 사용자의 이름을 반환합니다.
     *
     * @return String
     */
    @Override
    public String getUsername() {
        return userDto.getUserNm();
    }

    /**
     * 계정이 만료되지 않았는지 여부를 반환합니다.
     * 현재 항상 false를 반환하므로, 모든 계정이 만료된 것으로 처리됩니다.
     *
     * @return boolean
     */
    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    /**
     * 계정이 잠기지 않았는지 여부를 반환합니다.
     *
     * @return boolean
     */
    @Override
    public boolean isAccountNonLocked() {
        return false;
    }


    /**
     * 자격 증명(비밀번호)이 만료되지 않았는지 여부를 반환합니다.
     *
     * @return boolean
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    /**
     * 계정이 활성화되어 있는지 여부를 반환합니다.
     *
     * @return boolean
     */
    @Override
    public boolean isEnabled() {
        return false;
    }
}

 

4. TokenUtils


💡 TokenUtils

- 해당 Utils에서는 지정한 규칙에 따라서 토큰을 생성하고 유효성을 체크하며 토큰을 기반으로 사용자 정보를 조회하는 유틸입니다.
package com.adjh.springboot3security.common.utils;

import com.adjh.springboot3security.model.dto.UserDto;
import com.adjh.springboot3security.model.dto.ValidTokenDto;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * JWT의 구성요소를 생성하고 최종적으로 JWT를 생성하여 유효성을 체크하는 유틸입니다.
 *
 * @author lee
 * @fileName TokenUtils
 * @since : 10/1/24
 */
@Log4j2
@Component
public class TokenUtils {

    private static SecretKey JWT_SECRET_KEY;

    /**
     * JWT_SECRET_KEY 변수값에 환경 변수에서 불러온 SECRET_KEY를 주입합니다.
     *
     * @param jwtSecretKey
     */
    public TokenUtils(@Value("${jwt.secret}") String jwtSecretKey) {
        TokenUtils.JWT_SECRET_KEY = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
    }


    /**
     * '토큰의 만료기간'을 지정하는 메서드
     *
     * @return {Date} Calendar
     */
    private static Date createExpiredDate() {
        Calendar c = Calendar.getInstance();
//        c.add(Calendar.SECOND, 3);        // 10초
        c.add(Calendar.HOUR, 1);             // 1시간
        // c.add(Calendar.HOUR, 8);             // 8시간
        // c.add(Calendar.DATE, 1);             // 1일
        return c.getTime();
    }

    /**
     * JWT의 '헤더' 값을 생성해주는 메서드
     *
     * @return HashMap<String, Object>
     */
    private static Map<String, Object> createHeader() {
        return Jwts.header()
                .add("typ", "JWT")
                .add("alg", "HS256")
                .add("regDate", System.currentTimeMillis()).build();
    }

    /**
     * '사용자 정보' 기반으로 'Claims' 생성하는 메서드
     *
     * @param userDto 사용자 정보
     * @return Map<String, Object>
     */
    private static Map<String, Object> createClaims(UserDto userDto) {
        // 공개 클레임에 사용자의 이름과 이메일을 설정하여 정보를 조회할 수 있다.
        Map<String, Object> claims = new HashMap<>();

        log.info("userId :" + userDto.getUserId());
        log.info("userNm :" + userDto.getUserNm());

        claims.put("userId", userDto.getUserId());
        claims.put("userNm", userDto.getUserNm());
        return claims;
    }

    /**
     * 토큰을 기반으로 유효한 토큰인지 여부를 반환해주는 메서드
     * - Claim 내에서 사용자 정보를 추출합니다.
     *
     * @param token String  : 토큰
     * @return boolean      : 유효한지 여부 반환
     */
    public static boolean isValidToken(String token) {
        try {
            Claims claims = getTokenToClaims(token);
            log.info("expireTime :" + claims.getExpiration());
            log.info("userId :" + claims.get("userId"));
            log.info("userNm :" + claims.get("userNm"));
            return true;
        } catch (ExpiredJwtException exception) {
            log.debug("token expired " + token);
            log.error("Token Expired" + exception);
            return false;
        } catch (JwtException exception) {
            log.debug("token expired " + token);
            log.error("Token Tampered" + exception);
            return false;
        } catch (NullPointerException exception) {
            log.debug("token expired " + token);
            log.error("Token is null" + exception);
            return false;
        }
    }


    /**
     * 사용자 정보를 기반으로 토큰을 생성하여 반환 해주는 메서드
     *
     * @param userDto UserDto : 사용자 정보
     * @return String : 토큰
     */
    public static String generateJwt(UserDto userDto) {
        log.debug("생성된 JWT Secret Key: " + JWT_SECRET_KEY);
        // 사용자 시퀀스를 기준으로 JWT 토큰을 발급하여 반환해줍니다.
        JwtBuilder builder = Jwts.builder()
                .setHeader(createHeader())                              // Header 구성
                .claims(createClaims(userDto))                          // Payload - Claims 구성
                .subject(String.valueOf(userDto.getUserSq()))           // Payload - Subject 구성
                .signWith(JWT_SECRET_KEY)                               // Signature 구성
                .expiration(createExpiredDate());                       // Expired Date 구성
        return builder.compact();
    }


    /**
     * Refresh Token으로 기간을 14일로 지정합니다.
     *
     * @return
     */
    private static Date createRefreshTokenExpiredDate() {
        Calendar c = Calendar.getInstance();
        c.add(Calendar.DATE, 14);
        return c.getTime();
    }

    public static String generateRefreshToken(UserDto userDto) {

        log.debug("JWT Secret Key: " + JWT_SECRET_KEY);
        return Jwts.builder()
                .setHeader(createHeader())
                .claims(createClaims(userDto))
                .subject(String.valueOf(userDto.getUserSq()))
                .signWith(JWT_SECRET_KEY)
                .expiration(createRefreshTokenExpiredDate())
                .compact();
    }


    /**
     * 'Header' 내에서 'Token' 정보를 반환하는 메서드
     *
     * @param header 헤더
     * @return String
     */
    public static String getHeaderToToken(String header) {
        return header.split(" ")[1];
    }


    /**
     * 'JWT' 내에서 'Claims' 정보를 반환하는 메서드
     *
     * @param token : 토큰
     * @return Claims : Claims
     */

    private static Claims getTokenToClaims(String token) {
        System.out.println("확인111  : " + token);
        System.out.println("확인222  : " + JWT_SECRET_KEY);
        return Jwts.parser()
                .verifyWith(JWT_SECRET_KEY)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    /**
     * 'Claims' 내에서 '사용자 아이디'를 반환하는 메서드
     *
     * @param token : 토큰
     * @return String : 사용자 아이디
     */
    public static String getClaimsToUserId(String token) {
        Claims claims = getTokenToClaims(token);
        return claims.get("userId").toString();
    }
}


 

5. application-loc.yml


💡 application-loc.yml

- 위에서 불러오는 고유한 JWT 키값을 환경설정 파일에서 지정을 합니다.
- 해당 KEY는 반드시 32자 이상으로 문자로 구성이 되어야 합니다. 이는 HS256 알고리즘을 사용하는데에 권고 사항입니다.
# Spring Boot Custom Value
jwt:
  # 2. spring Boot Security + Key 설정(* 반드시 32이상 글자로 구성되어야 함)
  secret: 7Hs9x2mK4Lp6Rw3tYzAqBcDfGjNvXeUi

 

 

 

6. WebSecurityConfig


💡 WebSecurityConfig

- Spring Security 환경 설정을 구성하기 위한 클래스입니다.
- 웹 서비스가 로드될 때 Spring Container 의해 관리가 되는 클래스이며 사용자에 대한 ‘인증’과 ‘인가’에 대한 구성을 Bean 메서드로 주입을 합니다.
번호 메서드 명 설명
1 webSecurityCustomizer() 정적 자원(Resource)에 대해 인증된 사용자의 접근 '인가' 설정을 담당
2 securityFilterChain() HTTP에 대한 '인증'과 '인가'를 담당하며, 인증 방식과 절차에 대한 설정을 수행
3 authenticationManager() 인증 메서드를 제공하는 매니저로, 'Provider'의 인터페이스 역할
4 customAuthenticationProvider() '인증' 제공자로 사용자의 이름과 비밀번호를 데이터베이스에 제공하여 반환
5 bCryptPasswordEncoder() 비밀번호 암호화를 위한 BCrypt 인코딩 수행
6 customAuthenticationFilter() 커스텀 '인증' 필터로 접근 URL, 데이터 전달방식 등 인증 과정 및 처리 설정
7 customLoginSuccessHandler() 사용자 정보가 맞을 경우 수행되는 Handler
8 customLoginFailureHandler() 사용자 정보가 맞지 않을 경우 수행되는 Handler
9 jwtAuthorizationFilter() JWT 토큰을 통한 사용자 인증
10 corsConfigurationSource() Cors 관련되어서 커스텀 설정을 합니다.
package com.adjh.springboot3security.config;

import com.adjh.springboot3security.config.filter.CustomAuthenticationFilter;
import com.adjh.springboot3security.config.filter.JwtAuthorizationFilter;
import com.adjh.springboot3security.config.handler.CustomAuthFailureHandler;
import com.adjh.springboot3security.config.handler.CustomAuthSuccessHandler;
import com.adjh.springboot3security.config.handler.CustomAuthenticationProvider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
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.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

/**
 * Spring Security 환경 설정을 구성하기 위한 설정 클래스입니다.
 * 웹 서비스가 로드 될때 Spring Container 의해 관리가 되는 클래스이며 사용자에 대한 ‘인증’과 ‘인가’에 대한 구성을 Bean 메서드로 주입을 합니다.
 *
 * @author : jonghoon
 * @fileName : WebSecurityConfig
 * @since : 10/1/24
 */
@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {


    /**
     * 1. 정적 자원(Resource)에 대해서 인증된 사용자가 정적 자원의 접근에 대해 ‘인가’에 대한 설정을 담당하는 메서드입니다.
     *
     * @return WebSecurityCustomizer
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        // 정적 자원에 대해서 Security를 적용하지 않음으로 설정
        return web -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    /**
     * 2. HTTP에 대해서 ‘인증’과 ‘인가’를 담당하는 메서드이며 필터를 통해 인증 방식과 인증 절차에 대해서 등록하며 설정을 담당하는 메서드입니다.
     *
     * @param http HttpSecurity
     * @return SecurityFilterChain
     * @throws Exception Exception
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)                                                          // CSRF 보호 비활성화
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))                              // CORS 커스텀 설정 적용
                .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())                                   // 우선 모든 요청에 대한 허용
                .addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class)                     // JWT 인증 (커스텀 필터)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))   // 세션 미사용 (JWT 사용)
                .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)      // 사용자 인증(커스텀 필터)
                .formLogin(AbstractHttpConfigurer::disable)                                                     // 폼 로그인 비활성화
                .build();
    }


    /**
     * 3. authenticate 의 인증 메서드를 제공하는 매니져로'Provider'의 인터페이스를 의미합니다.
     * - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
     *
     * @return AuthenticationManager
     */
    @Bean
    public AuthenticationManager authenticationManager() {
        return new ProviderManager(customAuthenticationProvider());
    }

    /**
     * 4. '인증' 제공자로 사용자의 이름과 비밀번호를 데이터베이스에 제공하여 반환받습니다.
     * - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
     *
     * @return CustomAuthenticationProvider
     */
    @Bean
    public CustomAuthenticationProvider customAuthenticationProvider() {
        return new CustomAuthenticationProvider(bCryptPasswordEncoder());
    }

    /**
     * 5. 비밀번호를 암호화하기 위한 BCrypt 인코딩을 통하여 비밀번호에 대한 암호화를 수행합니다.
     *
     * @return BCryptPasswordEncoder
     */
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }


    /**
     * 6. 커스텀을 수행한 '인증' 필터로 접근 URL, 데이터 전달방식(form) 등 인증 과정 및 인증 후 처리에 대한 설정을 구성하는 메서드입니다.
     *
     * @return CustomAuthenticationFilter
     */
    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        customAuthenticationFilter.setFilterProcessesUrl("/api/v1/user/login");     // 접근 URL
        customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler());    // '인증' 성공 시 해당 핸들러로 처리를 전가한다.
        customAuthenticationFilter.setAuthenticationFailureHandler(customLoginFailureHandler());    // '인증' 실패 시 해당 핸들러로 처리를 전가한다.
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    /**
     * 7. Spring Security 기반의 사용자의 정보가 '맞을 경우' 수행이 되며 결과값을 리턴해주는 Handler
     *
     * @return CustomLoginSuccessHandler
     */
    @Bean
    public CustomAuthSuccessHandler customLoginSuccessHandler() {
        return new CustomAuthSuccessHandler();
    }

    /**
     * 8. Spring Security 기반의 사용자의 정보가 '맞지 않을 경우' 수행이 되며 결과값을 리턴해주는 Handler
     *
     * @return CustomAuthFailureHandler
     */
    @Bean
    public CustomAuthFailureHandler customLoginFailureHandler() {
        return new CustomAuthFailureHandler();
    }


    /**
     * 9. JWT 토큰을 통하여서 사용자를 인증합니다.
     *
     * @return JwtAuthorizationFilter
     */
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter();
    }


    /**
     * 10. CORS에 대한 설정을 커스텀으로 구성합니다.
     *
     * @return CorsConfigurationSource
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("*"));      // 허용할 오리진
        configuration.setAllowedMethods(List.of("*"));      // 허용할 HTTP 메서드
        configuration.setAllowedHeaders(List.of("*"));      // 모든 헤더 허용
        configuration.setAllowCredentials(true);                // 인증 정보 허용
        configuration.setMaxAge(3600L);                         // 프리플라이트 요청 결과를 3600초 동안 캐시

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);             // 모든 경로에 대해 이 설정 적용
        return source;
    }
}

 
 

7. JwtAuthorizationFilter


💡 JwtAuthorizationFilter

- Spring Security의 필터 체인에서 JWT(JSON Web Token) 인증을 처리하는 중요한 컴포넌트입니다.
- JWT 토큰이 존재하지 않는 경우 다음 필터로 수행되도록 처리가 됩니다.
- JWT 토큰이 존재하는 경우 유효한지에 대해 검증을 수행합니다.

메서드 설명
doFilterInternal - JWT 인증 처리의 주요 로직을 수행합니다.
- 토큰이 필요하지 않은 API 엔드포인트를 확인하고 처리합니다.
- 클라이언트 요청의 Authorization 헤더에서 토큰을 추출하고 검증합니다.
- 토큰이 유효한 경우 사용자 ID를 확인하고 다음 필터로 진행합니다.
- 예외 발생 시 에러 응답을 생성합니다.
jwtTokenError - JWT 관련 예외 발생 시 JSON 형태의 에러 응답을 생성합니다.
- 토큰 만료, 잘못된 토큰, 기타 오류에 대한 메시지를 구성합니다.
- 에러 상태, 코드, 메시지, 이유를 포함한 JSON 응답을 반환합니다.

 

package com.adjh.springboot3security.config.filter;

import com.adjh.springboot3security.common.utils.TokenUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인하는 필터 역할의 클래스입니다.
 *
 * @author : jonghoon
 * @fileName : CustomAuthenticationFilter
 * @since : 10/1/24
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
    private static final String HTTP_METHOD_OPTIONS = "OPTIONS";

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

        // 1. 토큰이 필요하지 않는 경우에 대해 API Endpoint 관리
        List<String> notUseJwtUrlList = Arrays.asList(
                "/api/v1/user/login",
                "/api/v1/token/token",
                "/user/login",
                "/token/token"
        );

        // 2. 토큰이 필요하지 않는 API 호출 발생 시 : 아래 로직 처리 없이 다음 필터로 이동
        if (notUseJwtUrlList.contains(request.getRequestURI())) {
            chain.doFilter(request, response);
            return;
        }

        // 3. 토큰이 필요없는 HTTP Method OPTIONS 호출 발생 시:  아래 로직 처리 없이 다음 필터로 이동
        if (request.getMethod().equalsIgnoreCase(HTTP_METHOD_OPTIONS)) {
            chain.doFilter(request, response);
            return;
        }

        // [STEP1] Client API 호출에서 "Authorization"를 속성을 확인합니다.
        String header = request.getHeader("Authorization");
        logger.debug("[+] header Check: " + header);

        try {
            // [STEP2-1] Header 내에 토큰이 존재하는 경우 : null, isEmpty() 체크 수행
            if (StringUtils.isNotBlank(header)) {

                // [STEP2] Header 내에 토큰을 추출합니다.
                String token = TokenUtils.getHeaderToToken(header);
                System.out.println("추출된 토큰 :: " + token);

                // [STEP3] 추출한 토큰이 유효한지 여부를 체크합니다.
                if (TokenUtils.isValidToken(token)) {

                    // [STEP4] 토큰을 기반으로 사용자 아이디를 반환 받는 메서드
                    String userId = TokenUtils.getClaimsToUserId(token);
                    logger.debug("[+] userId Check: " + userId);

                    // [STEP5] 사용자 아이디가 존재하는지 여부 체크
                    if (StringUtils.isNotBlank(userId)) {
                        chain.doFilter(request, response);      // 리소스로 접근
                    } else {
                        throw new Exception("토큰 내에 사용자 아이디가 존재하지 않습니다");    // 사용자 아이디가 존재하지 않는 경우
                    }
                } else {
                    throw new Exception("토큰이 유효하지 않습니다.");                      // 토큰이 유효하지 않은 경우
                }
            } else {
                throw new Exception("토큰이 존재하지 않습니다.");                          // 토큰이 존재하지 않는 경우
            }
        }
        // Token 내에 Exception 발생 하였을 경우 : 클라이언트에 응답값을 반환하고 종료합니다.
        catch (Exception e) {
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json");
            PrintWriter printWriter = response.getWriter();
            String jsonResponse = jwtTokenError(e);
            printWriter.print(jsonResponse);
            printWriter.flush();
            printWriter.close();
        }
    }

    /**
     * JWT 내에 Exception 발생 시 JSON 형태의 예외 응답값을 구성하는 메서드
     *
     * @param e Exception
     * @return String
     */
    private String jwtTokenError(Exception e) {
        ObjectMapper objectMapper = new ObjectMapper();
        Map<String, Object> jsonMap = new HashMap<>();
        String resultMsg = "";

        // [CASE1] JWT 기간 만료
        if (e instanceof ExpiredJwtException) {
            resultMsg = "토큰 기간이 만료되었습니다.";
        }
        // [CASE2] JWT내에서 오류 발생 시
        else if (e instanceof JwtException) {
            resultMsg = "잘못된 토큰이 발급되었습니다.";
        }
        // [CASE3] 이외 JWT내에서 오류 발생
        else {
            resultMsg = "OTHER TOKEN ERROR";
        }
        // Custom Error Code 구성
        jsonMap.put("status", 403);
        jsonMap.put("code", "9999");
        jsonMap.put("message", resultMsg);
        jsonMap.put("reason", e.getMessage());

        try {
            return objectMapper.writeValueAsString(jsonMap);
        } catch (JsonProcessingException err) {
            log.error("내부적으로 JSON Parsing Error 발생 " + err);
            return "{}"; // 빈 JSON 객체를 반환
        }
    }
}

 
 

8. CustomAuthenticationFilter


💡 CustomAuthenticationFilter

- Security의 UsernamePasswordAuthenticationFilter를 확장하여 사용자 정의 인증 로직을 구현합니다.
- 주로 폼 기반의 로그인 요청을 처리하고 인증 토큰을 생성하는 역할을 합니다.

메서드 명 설명
attemptAuthentication(HttpServletRequest request, HttpServletResponse response) - 지정된 URL로 form 전송을 했을 때 파라미터 정보를 가져옵니다.
- AuthenticationManager를 사용하여 인증을 시도합니다.
getAuthRequest(HttpServletRequest request) - Request로 받은 ID와 패스워드를 기반으로 인증 토큰을 발급합니다.
- ObjectMapper를 사용하여 요청 본문에서 UserDto 객체를 추출합니다.
- ID와 암호화된 패스워드로 UsernamePasswordAuthenticationToken을 생성합니다.

 

package com.adjh.springboot3security.config.filter;

import com.adjh.springboot3security.model.dto.UserDto;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

/**
 * 아이디와 비밀번호 기반의 데이터를 Form 데이터로 전송을 받아 '인증'을 담당하는 필터입니다.
 */
@Slf4j
@Component
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    /**
     * 지정된 URL로 form 전송을 하였을 경우 파라미터 정보를 가져온다.
     *
     * @param request  from which to extract parameters and perform the authentication
     * @param response the response, which may be needed if the implementation has to do a
     *                 redirect as part of a multi-stage authentication process (such as OpenID).
     * @return Authentication {}
     * @throws AuthenticationException {}
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authRequest;
        try {
            authRequest = getAuthRequest(request);
            setDetails(request, authRequest);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * Request로 받은 ID와 패스워드 기반으로 토큰을 발급한다.
     *
     * @param request HttpServletRequest
     * @return UsernamePasswordAuthenticationToken
     * @throws Exception e
     */
    private UsernamePasswordAuthenticationToken getAuthRequest(HttpServletRequest request) throws Exception {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true);
            UserDto user = objectMapper.readValue(request.getInputStream(), UserDto.class);
            log.debug("1.CustomAuthenticationFilter :: userId:" + user.getUserId() + " userPw:" + user.getUserPw());

            // ID와 암호화된 패스워드를 기반으로 토큰 발급
            return new UsernamePasswordAuthenticationToken(user.getUserId(), user.getUserPw());
        } catch (UsernameNotFoundException ae) {
            throw new UsernameNotFoundException(ae.getMessage());
        }
    }

}

 
 
 

9. CustomAuthenticationProvider


 💡 CustomAuthenticationProvider

- 사용자의 인증을 수행하는 단계입니다.
- 클라이언트로부터 전달받은 값을 기반으로 데이터베이스를 조회하여서 사용자에 대한 인증을 수행합니다.

 

메서드 명 설명
authenticate(Authentication authentication) - 사용자 인증을 수행하는 메인 메서드입니다
- 전달받은 Authentication 객체에서 사용자 ID와 비밀번호를 추출합니다
- UserDetailsService를 통해 데이터베이스에서 사용자 정보를 조회합니다
- 비밀번호를 검증하고, 일치하지 않으면 BadCredentialsException을 발생시킵니다
- 인증이 성공하면 새로운 UsernamePasswordAuthenticationToken을 생성하여 반환합니다
supports(Class<?> authentication) - 이 AuthenticationProvider가 지원하는 인증 토큰 타입을 확인합니다
- UsernamePasswordAuthenticationToken 클래스와 일치하는지 확인합니다

package com.adjh.springboot3security.config.handler;

import com.adjh.springboot3security.model.dto.UserDetailsDto;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
 * 전달받은 사용자의 아이디와 비밀번호를 기반으로 비즈니스 로직을 처리하여 사용자의 ‘인증’에 대해서 검증을 수행하는 클래스입니다.
 * CustomAuthenticationFilter로 부터 생성한 토큰을 통하여 ‘UserDetailsService’를 통해 데이터베이스 내에서 정보를 조회합니다.
 */
@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserDetailsService userDetailsService;

    @NonNull
    private BCryptPasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        log.debug("2.CustomAuthenticationProvider");

        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;

        // 'AuthenticationFilter' 에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
        String userId = token.getName();
        String userPw = (String) token.getCredentials();

        // Spring Security - UserDetailsService를 통해 DB에서 아이디로 사용자 조회
        UserDetailsDto userDetailsDto = (UserDetailsDto) userDetailsService.loadUserByUsername(userId);

        if (!(userDetailsDto.getUserPw().equalsIgnoreCase(userPw))) {
            throw new BadCredentialsException(userDetailsDto.getUserNm() + "Invalid password");
        }
        return new UsernamePasswordAuthenticationToken(userDetailsDto, userPw, userDetailsDto.getAuthorities());
    }

    //
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

 
 

10. UserDetailsServiceImpl


💡UserDetailsServiceImpl

- 사용자 인증 정보를 로드하고 UserDetails 객체를 생성하는 역할을 담당합니다.
package com.adjh.springboot3security.service.impl;

import com.adjh.springboot3security.model.dto.UserDetailsDto;
import com.adjh.springboot3security.model.dto.UserDto;
import com.adjh.springboot3security.service.UserService;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

import java.util.Collections;

/**
 * UserDetailsService 구현체
 * 사용자 인증 정보를 로드하고 UserDetails 객체를 생성하는 역할을 담당
 *
 * @author : jonghoon
 * @fileName : UserDto
 * @since : 10/1/24
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserService userService;

    public UserDetailsServiceImpl(UserService userService) {
        this.userService = userService;
    }

    /**
     * 사용자 ID를 받아 해당 사용자의 인증 정보를 로드합니다.
     *
     * @param userId the username identifying the user whose data is required.
     * @return
     */
    @Override
    public UserDetails loadUserByUsername(String userId) {

        // [STEP1] 사용자 아이디를 조회하여 존재하지 않는 경우 오류를 반환합니다.
        if (userId == null || userId.isEmpty()) {
            throw new AuthenticationServiceException("사용자 ID가 비어있습니다.");
        }

        // [STEP2] 서비스를 호출하여 실제 데이터베이스 조회를 통해서 사용자 정보를 조회합니다.
        return userService.login(UserDto.builder().userId(userId).build())
                .map(user -> new UserDetailsDto(user, Collections.singleton(new SimpleGrantedAuthority(user.getUserId()))))
                .orElseThrow(() -> new BadCredentialsException("사용자 정보가 올바르지 않습니다: " + userId));
    }
}

 
 

11. UserService


💡 UserService

- 사용자 정보를 조회해 오기 위한 인터페이스입니다.
package com.adjh.springboot3security.service;

import com.adjh.springboot3security.model.dto.UserDto;

import java.util.List;
import java.util.Optional;

/**
 * 사용자 정보를 조회해오기 위한 인터페이스입니다.
 */
public interface UserService {
    Optional<UserDto> login(UserDto userVo);

    List<UserDto> selectUserList(UserDto userDto);
}

 
 

12. CustomAuthSuccessHandler


💡 CustomAuthSuccessHandler

- 사용자의 '인증'에 대해 성공하였을 때, 수행하여 사용자에게 사용자 정보 및 JWT에 대한 응답 값을 제공해 주는 Handler입니다.

 

 

package com.adjh.springboot3security.config.handler;

import com.adjh.springboot3security.common.utils.TokenUtils;
import com.adjh.springboot3security.model.dto.UserDetailsDto;
import com.adjh.springboot3security.model.dto.UserDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;

/**
 * 사용자의 '인증'에 대해 성공하였을때, 수행하여 사용자에게 사용자 정보 및 JWT에 대한 응답 값을 제공해주는 Handler입니다.
 *
 * @author : jonghoon
 * @fileName : CustomAuthenticationFilter
 * @since : 10/1/24
 */
@Slf4j
@Configuration
public class CustomAuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        log.debug("3.1. CustomLoginSuccessHandler");

        // [STEP1] 사용자와 관련된 정보를 모두 조회합니다.
        UserDto userDto = ((UserDetailsDto) authentication.getPrincipal()).getUserDto();

        // [STEP2] 응답 데이터를 구성합니다.
        Map<String, Object> responseMap = new HashMap<>();
        responseMap.put("userInfo", userDto);

        // [STEP3] 사용자의 상태에 따라 응답 데이터를 설정합니다.
        if ("D".equals(userDto.getUserSt())) {
            responseMap.put("resultCode", 9001);
            responseMap.put("token", null);
            responseMap.put("failMsg", "휴면 계정입니다.");
        } else {
            responseMap.put("resultCode", 200);
            responseMap.put("failMsg", null);

            String token = TokenUtils.generateJwtToken(userDto);
            responseMap.put("token", token);
            response.addHeader("Authorization", "BEARER " + token);
        }

        // [STEP4] 구성한 응답 값을 JSON 형태로 전달합니다.
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");
        PrintWriter printWriter = response.getWriter();
        printWriter.write(objectMapper.writeValueAsString(responseMap));
        printWriter.flush();
        printWriter.close();
    }
}

 
 

13. CustomAuthFailureHandler


💡 CustomAuthFailureHandler

- 사용자의 '인증'에 대해 실패하였을 때, 수행하여 사용자에게 응답 값을 제공해 주는 Handler입니다.

package com.adjh.springboot3security.config.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;

/**
 * 사용자의 '인증'에 대해 실패하였을떄, 수행하여 사용자에게 응답 값을 제공해주는 Handler입니다.
 *
 * @author : jonghoon
 * @fileName : CustomAuthenticationFilter
 * @since : 10/1/24
 */
@Slf4j
@Configuration
public class CustomAuthFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        log.debug("3.2. CustomAuthFailureHandler");

        String failMsg = "";

        // [STEP2] 발생한 Exception 에 대해서 확인합니다.
        if (exception instanceof AuthenticationServiceException ||
                exception instanceof BadCredentialsException ||
                exception instanceof LockedException ||
                exception instanceof DisabledException ||
                exception instanceof AccountExpiredException ||
                exception instanceof CredentialsExpiredException) {
            failMsg = "로그인 정보가 일치하지 않습니다.";
        }

        // [STEP3] 응답 값을 구성하고 전달합니다.
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");

        log.debug(failMsg);

        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("userInfo", null);
        resultMap.put("resultCode", 9999);
        resultMap.put("failMsg", failMsg);

        ObjectMapper objectMapper = new ObjectMapper();
        String jsonResponse = objectMapper.writeValueAsString(resultMap);

        PrintWriter printWriter = response.getWriter();
        printWriter.print(jsonResponse);
        printWriter.flush();
        printWriter.close();
    }
}

 
 

5) 결과화면


 

1. 데이터베이스 확인


💡 데이터베이스 확인

- 데이터베이스에 tb_user 테이블 내에 가입이 된 사용자가 아래와 같이 있다는 가정하에 이를 로그인하는 결과화면을 확인해 봅니다.

 
 

2. 클라이언트 로그인 : 실패


💡 클라이언트 로그인 : 실패

- 아래와 같이 데이터베이스에 있는 사용자 정보가 아닌 다른 비밀번호를 입력하였을 때, “로그인 정보가 일치하지 않습니다”라는 메시지와 함께 결과 코드를 9999라는 임시 응답 코드를 확인할 수 있습니다.

https://github.com/adjh54ir/blog-codes/tree/main/react-login

 
 

💡 API 서버 로그를 확인합니다.

- CustomAuthenticationFilter → CustomAuthenticationProvider → (데이터베이스 조회) → CustomAuthFailureHandler 과정으로 수행이 됨이 확인되었습니다.
- 최종적으로 데이터베이스 조회를 통해 인증이 되지 않은 사용자이기에 CustomAuthFailureHandler에서 처리하는 응답값을 클라이언트에게 반환하였습니다.

https://github.com/adjh54ir/blog-codes/tree/main/spring-boot3-security

 
 

3. 클라이언트 로그인 : 성공


💡 클라이언트 로그인 : 성공

- 아래와 같이 데이터베이스에 있는 사용자 정보와 동일하게 입력되었을 때, 200이라는 결과 코드를 받고 token이라는 값을 통해 JWT를 발급받음을 확인하였습니다.

https://github.com/adjh54ir/blog-codes/tree/main/react-login

 
 

💡 API 서버 로그를 확인합니다.

- CustomAuthenticationFilter → CustomAuthenticationProvider → (데이터베이스 조회) → CustomLoginSuccessHandler 과정으로 수행이 됨이 확인되었습니다.
- 최종적으로 데이터베이스 조회를 통해 인증된 사용자이기에 CustomLoginSuccessHandler에서 처리하는 사용자 정보 + JWT를 응답값으로 클라이언트에게 반환하였습니다.

https://github.com/adjh54ir/blog-codes/tree/main/spring-boot3-security

 

 


4. 클라이언트 : 토큰 기반 리소스 접근


 💡 클라이언트 : 토큰 기반 리소스 접근

- 인증을 받아서 발급 받은 jwt를 기반으로 리소스(Controller)에서 결과값을 받아오는 결과입니다.

 

 

💡 API를 호출할때 Header 내에 Authorization의 값으로 Bearer Token으로 accessToken을 전달합니다.

 

💡 리소스 접근 버튼을 누르면 API 호출이 되고 아래와 같이 콘솔에 리소스 정보를 조회해오는 것을 확인하였습니다.


 

 


 
 
오늘도 감사합니다. 😀
 
 
 

그리드형