LDAP 인증 로그인(+Spring security, jwt)

 

목차

    1. 개발 스펙

    - JDK 17

    - maven

    - springboot 3.0.3

    - spring security 6.0.2

    - openLDAP 2.4.32

    - postgresql

    - 스웨거 (springdoc-openapi-starter-webmvc-ui 2.0.4)

     

    2. 구현 목표

    2.1 로그인

    1. 클라이언트에서 ID, PW를 가지고 로그인 요청을 보낸다

    2. openLDAP 사용자 인증

    3. 개발 시스템내 해당 정보의 사용자 존재 여부 확인, 없으면 최초 로그인으로 판단 -> 개발 시스템 내 DB에 유저 정보를 생성

    4. 유저 정보 담은 JWT토큰 발행

    2.1.1 What is the JSON Web Token structure?

    JSON Web Token (JWT)은 웹 표준으로, 사용자 인증 정보를 안전하게 전달하기 위한 방식 중 하나입니다. JWT는 JSON 형식으로 이루어진 토큰이며, 일반적으로 사용자 인증 및 권한 부여에 사용됩니다.

    JWT는 토큰 자체가 정보를 포함하고 있어서 별도의 저장소나 데이터베이스를 요구하지 않습니다. 토큰에는 사용자의 ID나 권한 같은 정보를 포함하고 있으며, 이 정보는 서버 측에서 검증됩니다. 따라서 JWT를 사용하면 사용자 인증과 관련된 정보를 안전하게 전달할 수 있습니다.

     

    JWT를 사용하면 클라이언트와 서버 간의 세션이 필요하지 않으며, RESTful API와 같은 stateless한 애플리케이션에 적합합니다.

     

    구성요소
        Header :  JWT의 유형과 암호화 알고리즘 등을 지정
        Payload :  JWT에 포함되는 정보
        Signature : 토큰의 무결성을 보장하는 KEY

     

    jwt 정보 예시

    HTTP 헤더 중 Authorization은 HTTP 요청을 보낼 때 클라이언트 인증 정보를 서버에 전달하는 데 사용됩니다. 이 헤더는 클라이언트가 인증된 사용자임을 서버에 알리는 데 사용되며, 일반적으로 Basic 인증 또는 Bearer 토큰 인증에서 사용됩니다. JWT는 Bearer 토큰 인증을 사용하여 전달됩니다.

    http Headers 예시

    2.2 토큰 인증정보 확인

    1. 클라이언트에서 JWT 토큰이 요청 헤더에 포함되어 전송
    2. Filter에서 JWT 토큰의 유효성 검사 수행

        2.1 HTTP 요청 헤더에서 "Authorization" 헤더를 가져온다
        2.2 JWT 토큰의 prefix가 맞는지 검사(Spring Security에서는 일반적으로 "Bearer "를 prefix로 사용)
    3. JWT 토큰이 유효하면 Authentication 객체 생성

        3.1 JWT 토큰을 파싱하여 payload에서 username 가져온다

        3.2 username을 사용하여 UserDetails를 조회
        3.3 조회한 UserDetails와 JWT 토큰에서 가져온 username을 비교하여 유효성을 검사
    4. SecurityContext에 Authentication 객체 저장
    5. 성공시, 요청 처리
    6. 유효성 검사를 통과하지 못한 경우, AuthenticationEntryPoint를 호출하여 인증 실패 처리를 수행

    2.2.1 Spring Security

    - 스프링 기반의 애플리케이션 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크
    인증(Authentication) 절차를 거친 후 인가(Authorization) 절차를 진행한다. 이를 위해 접근 주체(Principal)을 아이디로, 비밀번호(Credential)를 접근 대상의 비밀번호로 사용하는 Credential 기반의 인증 방식을 사용한다. 

    Spring Security 주요 모듈

    GrantedAuthority는 현재 사용자(principal)가 가지고 있는 권한을 의미한다. ROLE_ADMIN나 ROLE_USER와 같이 ROLE_*의 형태로 사용하며, 보통 "roles" 이라고 한다. GrantedAuthority 객체는 UserDetailsService에 의해 불러올 수 있고, 특정 자원에 대한 권한이 있는지를 검사하여 접근 허용 여부를 결정한다.

     

    SecurityContextHolder

    Spring Security의 인증 모델의 핵심은 SecurityContextHolder에 있습니다. SecurityContextHolder에는 SecurityContext가 포함됩니다.

    SecurityContextHolder는 Spring Security에서 인증된 사용자의 세부 정보를 저장하는 곳입니다. Spring Security는 SecurityContextHolder가 어떻게 채워지는지 신경 쓰지 않습니다. 값이 포함되어 있으면 현재 인증된 사용자로 사용됩니다.

     

    인증된 주체(principal)에 대한 정보를 얻으려면 SecurityContextHolder에 액세스합니다.

    SecurityContext context = SecurityContextHolder.getContext();
    Authentication authentication = context.getAuthentication();
    String username = authentication.getName();
    Object principal = authentication.getPrincipal();
    Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

    Spring Security는 Authentication.getPrincipal()의 현재 값을 Spring MVC 인수에 대해 자동으로 해석해주는 AuthenticationPrincipalArgumentResolver를 제공합니다.

    Spring Security 3.2부터는 어노테이션을 추가하여 인수를 더 직접적으로 해결할 수 있습니다. 이를 위해 @AuthenticationPrincipal 어노테이션을 사용할 수 있습니다.

    import org.springframework.security.core.annotation.AuthenticationPrincipal;
    
    // ...
    
    @RequestMapping("/messages/inbox")
    public ModelAndView findMessagesForUser(@AuthenticationPrincipal CustomUser customUser) {
    
    // .. find messages for this user and return them ...
    }

    Spring Security는 Servlet Filter 기술을 이용하여 Web Application의 보안을 담당하는데, 그러한 Filter들이 연속적으로 적용되는 구조를 갖습니다. 하나의 Request에 대해 한 개의 Servlet이 적용되지만, Filter는 체인 구조를 이루기 때문에 여러개의 Filter가 적용될 수 있습니다. 또한, Filter는 다음과 같은 기능들을 수행할 수 있습니다.

    - Request 또는 Response 수정
    - 체인 내 다른 Filter나 Servlet 실행 여부 결정

    FilterChain

    클라이언트가 애플리케이션에 요청을 보내면, 컨테이너는 FilterChain을 생성합니다. 이 FilterChain에는 요청 URI의 경로에 따라 처리할 Filter 인스턴스와 Servlet이 포함됩니다. Spring MVC 애플리케이션에서 Servlet은 DispatcherServlet 인스턴스입니다.

    ExceptionTranslationFilter

    3. open LDAP 설치 및 설정

    3.1 window에 openLDAP 설치

    (참고사이트 : OpenLdap install in Windows )

    위 글에 설치부터 Administrator 사용법까지 자세하게 나와있다.

     

    3.2 추가 사항

    - 설치 파일 다운로드 페이지에 들어가서 Files 탭을 누르면 여러 버전의 파일들을 볼 수 있는데, 최신 버전의 경우 시리얼키를 입력해야한다.(설치 정보를 스크린샷으로 찍어 메일을 보내라고 안내 나와있는데 답변은 받지 못했다.)

    openldap-2.4.32 버전에서는 시리얼키가 필요 없어 해당 버전으로 설치를 했다.

    - 설정 파일 관련 ( 참고 사이트 : 5분 안에 구축하는 LDAP )

    - 도메인, 사용자 이름, 부서 이름 설정은 위 사이트를 참고하였다.

    - FQDN 변경시 윈도우 버전의 경우 위 사이트와 달리 openLDAP(설치경로)\libexec\StartLDAP.cmd 내 값을 변경해주어야 한다. 아래 코드에서 SET FQDN 부분.

    (FQDN(Fully Qualified Domain Name): 호스트와 도메인을 함께 명시하여 전체 경로를 모두 표기하는 것)

    @echo off
    
    Rem SET HOME=
    
    SET ODBCINI=..\etc\odbc.ini
    
    SET ODBCSYSINI=..\etc
    
    SET FREETDS=..\etc\freeTDS.conf
    
    SET RANDFILE=..\bin\rfile.rnd
    
    SET LDAPCONF=..\etc\openldap\ldap.conf
    
    SET LDAPRC=..\bin\ldaprc
    
    Rem SET KRB5_CONFIG=/path/to/krb5.conf
    
    Rem SET FQDN=localhost
    SET FQDN=111.111.1.11
    
    slapd.exe -d -1 -h "ldap://%FQDN%/ ldaps://%FQDN%/" -f ..\etc\openldap\slapd.conf

    3.3 LDAP?

    LDAP( Lightweight Directory Access Protocol)는 TCP/IP 위에서 동작하는 디렉터리 서비스 프로토콜입니다. 디렉터리는 사용자, 그룹, 컴퓨터 등의 객체를 계층적으로 구성하고, 이들 객체에 대한 정보를 저장하는데 사용됩니다. LDAP는 이러한 디렉터리 정보를 검색하고 수정하는데 사용되며, 기존의 X.500 디렉터리 서비스보다 가벼운 프로토콜로 개발되어 인터넷 환경에서 보다 쉽게 이용할 수 있습니다.

    LDAP는 간단한 구조와 프로토콜을 가지고 있으며, 다양한 운영 체제에서 지원됩니다. 또한 LDAP를 이용하여 중앙 집중적으로 사용자, 그룹, 권한 등의 정보를 관리할 수 있어, 대규모 조직의 인프라 관리에 유용하게 사용됩니다. 예를 들어, 기업에서는 LDAP를 이용하여 직원 정보, 부서 정보, 권한 정보 등을 관리하며, 이를 기반으로 인증 및 인가를 수행하는 보안 시스템을 구축할 수 있습니다.

     

    단순하게 설명하자면 개체에 대한 정보를 저장하여 구성하는 계층적 DB라고도 할 수 있다. 각 개체는 항목들로 이루어져있으며, 계층 내 특정 위치를 갖는다. 컨테이너 객체와 리프 객체로 나뉘어지며 각 개체는 고유이름(DN), 속성모음, 개체모음(ex. objectClass : top, person 등)으로 이루어진다.

     

    속성은 cn, dc, ou, mail 등이 있는데 대표적으로 cn=일반이름, dc=도메인 구성 요소, ou=조직 단위이다.

     

    4. 실제 구현

    4.1 SecurityConfig

    springboot security 관련 설정 클래스

    /**
     * 인증 및 권한 처리
     */
    @Configuration
    @RequiredArgsConstructor
    @Slf4j
    public class SecurityConfig {
      private static final String[] DOC_URLS = {
          "/api-docs", "/api-docs/**", "/v2/api-docs", "/swagger-resources/**", "/swagger-ui.html","/swagger-ui/**"
      };
      private final LdapTokenAuthenticationEntryPoint ldapTokenAuthenticationEntryPoint;
      private final LdapAccessDeniedHandler ldapAccessDeniedHandler;
      private final LdapAuthenticationFilter ldapAuthenticationFilter;
    
      @Bean
      public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers(DOC_URLS);
      }
    
      @Bean
      public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
      }
    
      @Bean
      public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
      }
    
    
      @Bean
      public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        log.info("SecurityConfig.filterChain(필터체인)=============================================");
        httpSecurity
            .httpBasic().disable() // 기본  HttpBasic 인증 설정 비활성화
            //.cors().disable()
            .cors(c -> {
              CorsConfigurationSource source = request -> {
                //Cors 허용 패턴
                CorsConfiguration config = new CorsConfiguration();
                config.setAllowedOrigins(
                    List.of("*")
                );
                config.setAllowedMethods(
                    List.of("*")
                );
                return config;
              };
              c.configurationSource(source);
            })
            .csrf().disable() // csrf(사이트 간 요청 위조) 보안 비활성화(쿠키 기반이 아니라 jwt 기반이므로 사용안함)
    
            // 세션 사용 안함
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
    
            // 권한 체크 start =========================================================================================
            .authorizeHttpRequests()
    
            //.anyRequest().permitAll() // 인증 없이 모든 접근 가능
    
            // LDAP 기능 관련 권한(USER, GROUP)
              .requestMatchers("/auth/login").permitAll() //회원가입, 로그인 모두 승인
    
            // 권한 체크 end =========================================================================================
    
            // 에러 핸들링
            .and()
            .addFilterBefore(ldapAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling()
            .authenticationEntryPoint(ldapTokenAuthenticationEntryPoint)
            .accessDeniedHandler(ldapAccessDeniedHandler)
    
            // 필터 추가
            .and()
            .formLogin().disable();
    
        return httpSecurity.build();
      }
    }

     

    4.1.1 설정 관련 항목 설명

    httpbasic

    HTTP Basic은 클라이언트-서버 애플리케이션에서 매우 간단한 인증 방식 중 하나입니다. 클라이언트가 서버에 요청을 보낼 때마다, 요청 헤더에 인증 정보가 담겨서 전송됩니다.

    HTTP Basic 인증의 흐름은 다음과 같습니다.

    1. 클라이언트가 서버에 요청을 보냅니다.
    2. 서버는 클라이언트에게 401 Unauthorized 응답을 반환합니다.
    3. 클라이언트는 Authorization 헤더에 인증 정보를 담아 요청을 다시 보냅니다.
    4. 서버는 Authorization 헤더에서 인증 정보를 추출하여 인증을 수행합니다.
    5. 인증이 성공하면, 서버는 요청에 대한 응답을 반환합니다.
    Authorization 헤더는 "Basic" + "스페이스" + "Base64 인코딩된 인증 정보" 형태로 구성됩니다. Base64 인코딩은 암호화가 아니기 때문에, 암호화되지 않은 평문으로 쉽게 디코딩될 수 있습니다. 따라서, HTTPS와 함께 사용하지 않는 이상, 인증 정보가 도청될 가능성이 있습니다.

    Spring Security에서는 HttpSecurity를 사용하여 HTTP Basic 인증을 구성할 수 있습니다. 예를 들어, 다음과 같이 구성할 수 있습니다.

    @Configuration
    @EnableWebSecurity
    public class BasicAuthConfiguration extends WebSecurityConfigurerAdapter {
     
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                .httpBasic();
        }
    }

     

    위 예제에서는 모든 요청에 대해 인증을 요구하고, HTTP Basic 인증을 사용하도록 구성했습니다. 이렇게 구성하면, 클라이언트가 요청을 보낼 때마다, 요청 헤더에 인증 정보가 담겨서 전송됩니다.

     

    CORS(교차 출처 리소스 공유) 

    - CORS는 Cross-Origin Resource Sharing의 줄임말로, 한국어로 직역하면 교차 출처 리소스 공유라고 해석할 수 있다. 여기서 “교차 출처”라고 하는 것은 “다른 출처”를 의미한다.

    웹 생태계에는 다른 출처로의 리소스 요청을 제한하는 것과 관련된 두 가지 정책이 존재한다. CORS, 그리고 SOP(Same-Origin Policy)이다. SOP는 “같은 출처에서만 리소스를 공유할 수 있다”라는 규칙을 가진 정책이다. 그러나 웹은 오픈스페이스 환경을 가지고 있기에 몇 가지 예외 조항을 두고 있는데 그중 하나가 CORS 정책을 지킨 리소스 요청이다.

    같은 출처란 protocol과 Host, port까지 일치해야 같은 출처라고 인정된다. 그러나 명확한 정의가 표준으로 정해진 것은 아니기 때문에 어떤 경우에는 다른 출처로 판단될 수도 있다.

     

    cors를 지킨 리소스 요청 예시

    이미지 출처 : https://evan-moon.github.io/2020/05/21/about-cors/#sopsame-origin-policy

    1. 먼저, www.example.com에서 api.example.com으로 예비요청을 보낸다.
    2. api.example.com에서는 예비요청을 받았을 때, 요청 헤더의 Origin 정보를 확인한다. 이때, api.example.com에서 자원을 공유할 수 있는 도메인이 www.example.com인 경우에는, 응답 헤더에 Access-Control-Allow-Origin: https://www.example.com와 같은 형태로, 요청을 보낸 도메인의 정보를 함께 전송한다.
    3. www.example.com에서는 응답 헤더의 Access-Control-Allow-Origin 정보를 확인하여, 해당 도메인에서 자원을 공유할 수 있는지 여부를 판단한다. 만약 자원을 공유할 수 있다면, 웹 어플리케이션에서 api.example.com의 자원을 사용할 수 있다.

     

    cors의 경우 서버뿐만 아니라 프론트에서도 역할을 해주어야한다!

    (참고 사이트 : 악명 높은 CORS 개념 & 해결법 - 정리 끝판왕 )

    출처를 비교하는 로직은 서버에 구현된 스펙이 아닌 브라우저에 구현된 스펙이다. 브라우저에는 에러가 뜨지만, 정작 서버 쪽에는 정상적으로 응답을 했다고 하기 때문에 난항은 겪는 것이다. 즉, 응답 데이터는 멀쩡하지만 브라우저 단에서 받을수 없도록 차단을 한 것이다.

     

    아래는 백엔드에서 구현한 cors 처리 소스이다. setAllowedOrigins에 허용하는 도메인을 추가해주면 된다. 아래는 모든 사이트를 허용한 상태이다.

    .cors(c -> {
      CorsConfigurationSource source = request -> {
        //Cors 허용 패턴
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(
            List.of("*")
        );
        config.setAllowedMethods(
            List.of("*")
        );
        return config;
      };
      c.configurationSource(source);
    })

     

    CSRF(Cross-Site Request Forgery)

     

     

    이미지 출처 :&nbsp;https://medium.com/tresorit-engineering/modern-csrf-mitigation-in-single-page-applications-695bcb538eec

    CSRF는 공격자가 인증된 사용자의 권한을 이용하여 원치 않는 행위를 수행하도록 유도하는 공격입니다.
    공격자는 인증된 사용자의 세션 정보를 탈취하여 해당 웹사이트에 요청을 보내어 원치 않는 동작을 수행할 수 있습니다. 이 공격은 사용자의 인증 정보를 이용하므로 인증 과정 자체를 우회할 필요가 없어 공격의 난이도가 낮아집니다. CSRF 공격을 방지하기 위해서는 요청의 내용 중에서 악의적인 웹사이트가 제공할 수 없는 내용이 있어야 하며, 이를 통해 두 요청을 구별할 수 있어야 합니다.

    CSRF 공격 방지를 위해 Spring Security는 기본적으로 CSRF 공격 방지 기능을 제공합니다. 이를 이용하여 요청에 대한 인증 토큰(CSRF 토큰)을 생성하여 요청에 포함시켜 보안을 강화할 수 있습니다.

     

    LdapAccessDeniedHandler

    권한 예외 처리 클래스

    @Component
    public class LdapAccessDeniedHandler implements AccessDeniedHandler {
      @Override
      public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
          ObjectMapper mapper = new ObjectMapper();
          response.setContentType(MediaType.APPLICATION_JSON_VALUE);
          response.setCharacterEncoding("utf-8");
          response.setStatus(HttpServletResponse.SC_FORBIDDEN);
          response.getWriter().write(ErrorCode.DO_NOT_HAVE_PERMISSION.getMessage());
      }
    }

     

    LdapTokenAuthenticationEntryPoint

    인증 예외 처리 클래스. 다시 로그인 페이지로 리다이렉션 시키거나 오류 페이지로 보내는 등의 행동을 처리한다.

    @Component
    public class LdapTokenAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
      @Override
      public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String errorMsg = (String) request.getAttribute("ERR_MSG");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(ErrorCode.ENTER_THE_TOKEN.getMessage());
      }
    }
    

     

    4.2 전체적인 구현

    4.2.1 LDAP config

    LdapConfig 클래스

    ContextSource는 LdapTemplate을 만드는 데 사용

    @Configuration
    @Slf4j
    public class LdapConfig {
      @Value("${spring.ldap.url}")
      private String url;
    
      @Value("${spring.ldap.base}")
      private String base;
    
      @Value("${spring.ldap.userDn}")
      private String userDn;
    
      @Value("${spring.ldap.password}")
      private String password;
    
      @Bean
      public LdapContextSource contextSource() {
        LdapContextSource contextSource = new LdapContextSource();
    
        contextSource.setUrl(url);
        contextSource.setBase(base);
        contextSource.setUserDn(userDn);
        contextSource.setPassword(password);
    
        log.info("LdapContextSource contextSource()====================================== ", url);
    
        return contextSource;
      }
    
      @Bean
      public LdapTemplate ldapTemplate() {
        return new LdapTemplate(contextSource());
      }
    }

     

    LdapConfig관련 설정

    *.yml 형식으로 작성되었다.

    #LDAP
      ldap:
        url: ldap://localhost:port
        base: dc=my-domain,dc=com
        userDn: cn=Manager,dc=my-domain,dc=com
        password: password

     

    pom.xml

    <!-- LDAP -->
    <dependency>
       <groupId>org.springframework.ldap</groupId>
       <artifactId>spring-ldap-core</artifactId>
    </dependency>
    <dependency>
       <groupId>org.springframework.security</groupId>
       <artifactId>spring-security-ldap</artifactId>
    </dependency>
    <dependency>
       <groupId>com.unboundid</groupId>
       <artifactId>unboundid-ldapsdk</artifactId>
    </dependency>

     

    4.2.2 로그인

    Controller

    클라이언트로부터 요청을 받아 처리한다.

    /**
     * LDAP 인증을 받아 로그인 처리
     */
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/auth")
    @Tag(name = "로그인 controller", description = "LDAP 인증을 이용하여 사용자 로그인을 처리한다.")
    @Slf4j
    public class AuthController {
      private final AuthService authService;
    
      @Operation(summary = "Login method", description = "사용자의 ID와 PW를 받아 응답한다.")
      @ApiResponses(value = {
          @ApiResponse(responseCode = "200", description = "successful operation",
          content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = UserAuthLoginResponse.class))))
      })
      @PostMapping("/login")
      public ResponseEntity login(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "userId, password를 받아온다.") @RequestBody UserAuthLoginRequest request) {
        log.info("AuthController.login(/auth/login)=============================================");
        UserAuthLoginResponse response = authService.login(request);
    
        return ResponseEntity.status(HttpStatus.OK).body(response);
      }
    }

     

    Service

    클라이언트로부터 요청을 받아 처리한다.

    @Service
    @RequiredArgsConstructor
    @Slf4j
    public class AuthService {
      private final AuthenticationManager authenticationManager;
      private final JwtTokenProvider jwtTokenProvider;
    
      public UserAuthLoginResponse login(UserAuthLoginRequest request) {
        log.info("AuthService.login()=============================================");
    
        String userId = request.getUserId();
        String password = request.getPassword();
        log.debug("id {}", userId);
        log.debug("pw {}", password);
    
        // LDAP 인증(실 구현체인 LdapAuthenticationProvider.authenticate() 호출)
        Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(userId, password));
    
        // 토큰 생성
        SecurityUser user = (SecurityUser) authentication.getPrincipal(); // 토큰 생성을 위해 인증객체에서 인증된 사용자 정보 불러오기
        String token = jwtTokenProvider.createToken(user.getMember().getId(), user.getMember().getUserId()); // access token
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getMember().getUserId()); // refresh toekn
    
        // 응답 세팅
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add("Authorization", "Bearer " + token);
    
        MemberDto memberDto = new MemberDto();
        BeanUtils.copyProperties(user.getMember(), memberDto);
    
        UserAuthLoginResponse response = new UserAuthLoginResponse(token, refreshToken, memberDto);
    
        return response;
    
      }
    }

     

    LdapAuthenticationProvider

    LDAP 서버 인증 후 사용자 생성 유/무를 처리한다.

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class LdapAuthenticationProvider implements AuthenticationProvider {
      private final UserService userService; // LDAP 인증 서비스
      private final MemberService memberService; // 물품관리 멤버 관련 서비스
    
      /**
       * 사용자 인증
       */
      @Override
      public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        log.info("LdapAuthenticationProvider.authenticate(사용자 인증 LDAP)=============================================");
    
        String userId = authentication.getName();
        String password = authentication.getCredentials().toString();
    
        // LDAP 내 사용자 인증
        SecurityUser securityUser = userService.authenticate(userId, password);
    
        // 비품관리 DB 내 사용자 존재 여부 체크
        Member member = memberService.getMemberByUserId(userId).orElse(null);
        if(member == null) {
          log.info("비품관리 DB내 사용자 없음. 최초 로그인으로 판단, 사용자 데이터 생성!!!");
          member = memberService.joinMember(userId, securityUser.getUsername());
        } else {
          log.info("비품관리 DB내 사용자 있음!!!");
          // UUID 세팅
          securityUser.getMember().setId(member.getId());
          // 비품관리 내 권한으로 재설정
          securityUser.setAuthorities(member.getRoles());
        }
        
        return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities());
      }
    
      @Override
      public boolean supports(Class<?> authentication) {
        return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
      }
    }
    

     

    JwtTokenProvider

    토큰 생성 및 검

    /**
     * 토큰 생성 및 검증
     */
    @Component
    @RequiredArgsConstructor
    @Slf4j
    public class JwtTokenProvider {
      private static final String HEADER_KEY = "Authorization";
      private static final String HEADER_VALUE = "Bearer ";
      @Value("${jwt.token.time}")
      private long TOKEN_VALID_HOUR = 1; //토큰 유효시간
      @Value("${jwt.refresh.token.time}")
      private long REFRESH_TOKEN_VALID_HOUR = 6; //토큰 유효시간
      @Value("${jwt.secretKey}")
      private String SECRETKEY = "secretKey";
      private final UserDetailsServiceImpl userDetailsService;
    
      /**
       * secretKey 초기화
       */
      @PostConstruct
      protected void init(){
        SECRETKEY = Base64.getEncoder().encodeToString(SECRETKEY.getBytes(StandardCharsets.UTF_8));
      }
    
      /**
       * 토큰 생성(LDAP)
       * java.util.Date는 지양하고 java.time을 주로 씀
       */
      public String createToken(UUID id, String userId) {
        log.info("JwtTokenProvider.createToken(UUID id, String userId) (토큰 생성(LDAP)=============================================");
        log.debug("UUID : {}", id);
        log.debug("userId : {}", userId);
    
        LocalDateTime now = LocalDateTime.now();
    
        return Jwts.builder()
            .setId(id.toString())
            .setSubject(userId)
            .setIssuedAt(Timestamp.valueOf(now))
            .setExpiration(Timestamp.valueOf(now.plusHours(TOKEN_VALID_HOUR)))
            .signWith(SignatureAlgorithm.HS256, SECRETKEY)
            .compact();
      }
    
      /**
       * 리프레쉬 토큰 생성(LDAP)
       * 갱신 요청을 위한 토큰이기 때문에 Access 토큰과는 달리 payload에 단순한 값만 가지고 있는다.
       */
      public String createRefreshToken(String userId) {
        log.info("JwtTokenProvider.createRefreshToken(String userId) (토큰 생성(LDAP)=============================================");
        log.debug("userId : {}", userId);
    
        LocalDateTime now = LocalDateTime.now();
    
        return Jwts.builder()
            .setSubject(userId)
            .setExpiration(Timestamp.valueOf(now.plusHours(REFRESH_TOKEN_VALID_HOUR)))
            .signWith(SignatureAlgorithm.HS256, SECRETKEY)
            .compact();
      }
    
      /**
       * 토큰 값 추출
       */
      public String resolveToken(HttpServletRequest request) {
        log.info("JwtTokenProvider.resolveToken(토큰 추출)=============================================");
        String bearerToken = request.getHeader(HEADER_KEY);
        if(bearerToken != null && bearerToken.startsWith(HEADER_VALUE)) {
          return bearerToken.substring(7);
        }
        return null;
      }
    
      /**
       * 토큰 유효성 검사
       */
      public boolean validateToken(String token){
        log.info("JwtTokenProvider.validateTtokenoken(토큰 유효성 검사)=============================================");
        try{
          Jwts.parser().setSigningKey(SECRETKEY).parseClaimsJws(token);
          return true;
        }
        catch (JwtException e) {
          // MalformedJwtException | ExpiredJwtException | IllegalArgumentException
          throw new CustomException(ErrorCode.ERROR_ON_TOKEN);
        }
      }
    
      /**
       * 토큰으로부터 userId 추출
       */
      public String extractUserId(String token) {
        log.info("JwtTokenProvider.extractUserId(토큰으로부터 userId 추출)=============================================");
        return Jwts.parser().setSigningKey(SECRETKEY).parseClaimsJws(token).getBody().getSubject();
      }
    
      /**
       * 토큰으로부터 id(UUID) 추출
       */
      public String extractId(String token) {
        log.info("JwtTokenProvider.extractUserId(토큰으로부터 id 추출)=============================================");
        return Jwts.parser().setSigningKey(SECRETKEY).parseClaimsJws(token).getBody().getId();
      }
    }

     

     

    토큰 관련 설정

    시크릿키는 설정 파일에 적어놓는것을 지양한다고 한다. 운영 환경에서는 환경변수등으로 설정해준다고들한다. 현재는 개발중이니 설정파일에서 사용하고 있다. 주석에 달린 링크는 시크릿키값을 자동으로 생성해주는 사이트 주소이다.

    #JWT
    #https://www.allkeysgenerator.com/Random/Security-Encryption-Key-Generator.aspx
    jwt:
      secretKey: 5u8x/A?D(G+KbPeShVmYp3s6v9y$B&E)
      token:
        time: 1
      refresh:
        token:
          time: 6

     

    pom.xml

    <!--JWT 의존성 추가-->
    <dependency>
       <groupId>io.jsonwebtoken</groupId>
       <artifactId>jjwt</artifactId>
       <version>0.9.1</version>
    </dependency>

     

    4.2.2 토큰 인증정보 확인

    아래 인증관련 architecture의 순서에 실제 구현 소스를 엮어 나열한다.

    form 기반 로그인 플로우

    1. 사용자 요청

    2. 3. AuthenticationFilter에서 UsernamePasswordAuthenticationToken을 생성하여 AuthenticaionManager에게 전달

     

    AuthenticationFilter나 UsernamePasswordAuthenticationFilter를 상속하여 구현할 수도 있지만 이 프로젝트의 경우 OncePerRequestFilter를 상속받아 구현하였다.

     

    - OncePerRequestFilter는 어느 서블릿 컨테이너에서나 요청 당 한 번의 실행을 보장하는 것을 목표로 한다.
    - 보통 인증(Authentication) 또는 인가(권한, Authorization)와 같이 한번만 거쳐도 되는 로직에서 사용한다.

     

    AuthenticaionManager에게 전달해야 하는데 실제 구현에서는 LDAP 관련 CRUD를 담당하는 UserService로 대신하였다.

    @Component
    @RequiredArgsConstructor
    @Slf4j
    public class LdapAuthenticationFilter extends OncePerRequestFilter {
      private final JwtTokenProvider jwtTokenProvider;
      private final UserService userService;
    
      @Override
      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("LdapAuthenticationFilter.doFilterInternal(필터 LDAP)=============================================");
    
        String token = jwtTokenProvider.resolveToken(request);
        log.debug("토큰 값 : {}",token);
    
        if (token != null && jwtTokenProvider.validateToken(token)) {
    
          // 토큰이 유효한 값인 경우 사용자 정보를 불러와 SecurityContextHolder에 저장한다
          String userId = jwtTokenProvider.extractUserId(token);
          //Authentication authentication = new UsernamePasswordAuthenticationToken(userId,"");
    
          // LdapAuthenticationProvider를 사용하여 인증을 처리한다
          // authentication = authenticationManager.authenticate(authentication);
          SecurityUser user = (SecurityUser) userService.loadUserByUserId(userId);
          UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    
          SecurityContextHolder.getContext().setAuthentication(authenticationToken);
          log.info("SecurityContextHolder.getContext().setAuthentication(authenticationToken)");
        }
        filterChain.doFilter(request, response);
      }

     

    4. 실제 인증을 할 AuthenticationProvider에게 Authentication객체(UsernamePasswordAuthenticationToken)을 다시 전달하여 인증을 요구한다.

     

    5. DB에서 사용자 인증 정보를 가져올 UserDetailsService 객체에게 사용자 아이디를 넘겨주고 DB에서 인증에 사용할 사용자 정보(사용자 아이디, 암호화된 패스워드, 권한 등)를 UserDetails(인증용 객체와 도메인 객체를 분리하지 않기 위해서 실제 사용되는 도메인 객체에 UserDetails를 상속하기도 한다.)라는 객체로 전달 받는다.

     

    6. AuthenticationProvider는 UserDetails 객체를 전달 받은 이후 실제 사용자의 입력정보와 UserDetails 객체를 가지고 인증을 시도한다.

     

    - UserDetailsService : Spring Security에서 유저의 정보를 가져오는 인터페이스

    - UserDetails : Spring Security에서 사용자의 정보를 담는 인터페이스

     

    UserService 클래스 내 public SecurityUser authenticate(String userId, String rawPassword) 메소드에서 처리하도록 구현했다.

      /**
       * 사용자 인증
       */
      public SecurityUser authenticate(String userId, String rawPassword) {
        log.info("UserService.authenticate(사용자 인증 LDAP)=============================================");
    
        LdapQuery query = LdapQueryBuilder.query().where("uid").is(userId);
    
        AuthenticatedLdapEntryContextMapper<DirContextOperations> mapper = new AuthenticatedLdapEntryContextMapper<DirContextOperations>() {
          @Override
          public DirContextOperations mapWithContext(DirContext dirContext, LdapEntryIdentification ldapEntryIdentification) {
            try {
              return (DirContextOperations) dirContext.lookup(ldapEntryIdentification.getRelativeName());
            }
            catch (NamingException e) {
              throw new RuntimeException("Failed to lookup " + ldapEntryIdentification.getRelativeName());
            }
          }
        };
        // 사용자 인증 후 사용자 정보 받아온다.
        DirContextOperations dirContextOperations = template.authenticate(query, rawPassword, mapper);
    
        return customLdapUserDetailsMapper.mapUserFromContext(dirContextOperations, userId, null);
      }

     

    application

    매핑을 위한 커스텀 클래스

    @Component
    @Slf4j
    public class CustomLdapUserDetailsMapper extends LdapUserDetailsMapper {
      @Value("${system.admin.userId}")
      private String ADMIN_USER_ID = "admin";
    
      /**
       *  LDAP 서버로부터 가져온 사용자 정보를 SecurityUser 객체에 매핑하고 반환
       */
      @Override
      public SecurityUser mapUserFromContext(DirContextOperations ctx, String userId, Collection<? extends GrantedAuthority> authorities) {
        log.info("CustomLdapUserDetailsMapper.mapUserFromContext(userDetails 객체 매핑)=============================================");
    
        Member member = new Member();
        member.setUserId(ctx.getStringAttribute("uid"));
        member.setName(ctx.getStringAttribute("cn"));
        member.setPassword(ctx.getStringAttribute("userpassword"));
        member.setPassword(ctx.getStringAttribute("mail"));
    
        // 관리자일 경우 권한 관리자로 세팅
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        grantedAuthorities.add(new SimpleGrantedAuthority(userId.equals(ADMIN_USER_ID) ? Authority.ROLE_ADMIN.name() : Authority.ROLE_USER.name()));
    
        return new SecurityUser(member, grantedAuthorities);
      }
    }

     

    UserDetails 구현체

    public class SecurityUser  implements UserDetails {
      private Member member;
      private Collection<? extends GrantedAuthority> authorities;
    
      @Override
      public String getPassword() {
        return member.getPassword();
      }
    
      @Override
      public String getUsername() {
        return  member.getUserId();
      }
    
      @Override
      public boolean isAccountNonExpired() {
        return ;
      }
    
      @Override
      public boolean isAccountNonLocked() {
        return ;
      }
    
      @Override
      public boolean isCredentialsNonExpired() {
        return ;
      }
    
      @Override
      public boolean isEnabled() {
        return ;
      }
    }

     

    7. 8. 9. 10. 인증이 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailureHandler를 실행한다.)

     

    4.2.3 JpaAudit

    BaseAuditingEntity의 생성자, 수정자 Audit 처리를 위한 클래스이다.

     

    JpaAuditConfig

    @Configuration
    public class JpaAuditConfig {
      @Bean
      public AuditorAware<UUID> auditorProvider() {
        // AuditorAware의 구현체 객체 생성
        // 최초 로그인시 member 테이블에 데이터 생성되면서 발생되는 Auditing 필터링하여 걸러냄
        SecurityContext context = SecurityContextHolder.getContext();
    
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(authentication ->
                !authentication.getPrincipal().equals("anonymousUser"))
            .map(Authentication::getPrincipal)
            .map(SecurityUser.class::cast)
            .map(SecurityUser::getMember)
            .map((Member::getId));
      }
    }

    AuditorAwareImpl

    최초 로그인시 사용자를 강제로 생성할때 예외가 발생하므로 아래와 같이 예외 처리를 해주었다.

    public class AuditorAwareImpl implements AuditorAware<UUID> {
      @Override
      public Optional<UUID> getCurrentAuditor() {
        // SecurityContextHolder에서 사용자 정보를 가져온다
         Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(null == authentication || !authentication.isAuthenticated()) {
          return null;
        }
        return Optional.of(((SecurityUser)authentication.getPrincipal()).getMember().getId());
      }
    }

     

    4.2.3 Swagger

    스프링 3.*대로 들어오면서 스웨거 설정이 변경되었다. 기존 스웨거를 사용하면 오류가 발생하니 체크하고 아래와 같이 구현하였다.

     

    pom.xml

    <!-- Swagger  -->
    <dependency>
       <groupId>org.springdoc</groupId>
       <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
       <version>2.0.4</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

     

    application.yml

    #Swaggerdoc
    springdoc:
      version: v1.0.0
      packages-to-scan: com.example
      swagger-ui:
        path: /api-docs
        title: example!
        description: API 문서입니다.
        version: 1.0.0
        tags-sorter: alpha
        operations-sorter: alpha
        disable-swagger-default-url: true
        display-query-params-without-oauth2: true
        doc-expansion: none
      api-docs:
        path: /api-docs/json
        groups.enabled: true
      cache:
        disabled: true
      group-configs:
        - group: AUTH
          paths-to-match: /auth/**
        - group: MEMBER
          paths-to-match: /member/**
        - group: USERS
          paths-to-match: /users/**
        - group: GROUP
          paths-to-match: /group/**

     

     

    OpenApiConfig

    @OpenAPIDefinition(
        info=@Info(title="${springdoc.swagger-ui.title}",
            description="${springdoc.swagger-ui.description}",
            version="${springdoc.swagger-ui.version}"))
    @RequiredArgsConstructor
    @Configuration
    public class OpenApiConfig {
    }

     

    URL

    springdoc.swagger-ui.path에 /api-docs으로 설정하고 http://server:port/api-docs으로 접속하면 스웨거 url로 리다이렉트되는것까지는 확인이 되었는데 404 오류가 발생하였다. 원인을 알아보기 위해 찾아보니 스웨거 버전이 올라가면서 url이 변경되었다.(이것 때문에 엄청 삽집함) http://server:port/swagger-ui.html 으로 접근하면 잘 나온다.

     

    기타

    Spring Security without the WebSecurityConfigurerAdapter

    (참고 사이트 : Spring Security without the WebSecurityConfigurerAdapter)

    springboot JWT를 검색하면 여러 정보들이 나오는데 WebSecurityConfigurerAdapter를 상속 받아 구현하는 경우 위 참고 사이트 혹은 configure 대신 filterChain으로 구현한 예제를 찾아볼 것을 추천한다.

     

    POSTMAN 사용법

    postman으로 테스트시 해당 토큰을 가지고 api를 호출해야하는 경우 'Authorization'탭>Type은 Bearer Token>Token에 해당 토큰을 담아 요청을 보내면 된다.

     

    springboot 내장 LDAP으로 사용자 인증 샘플 가이드

    ( 참고사이트 : 스프링 사이트에서 제공하는 가이드 )

    위 가이드를 한번 따라해보고 아래 참고 사이트를 방문하니 이해에 더 도움이 되었기에 남겨둔다.

     

    LDAP 구현 참고 프로젝트

    ( 참고 사이트 : LDAP 인증 )

    해당 사이트 하단에 LDAP 예제 프로젝트 Github 링크가 있다. 로그인부터 사용자crud, 그룹 crud 모두 구현되어 있어 많은 도움이 되었다.

     

    어노테이션 정리

    생성자 관련

    @NoArgsConstructor : 파라미터가 없는 기본 생성자를 생성해주는 롬복 어노테이션

    @AllArgsConstructor : 모든 필드값을 파라미터로 받는 생성자를 생성해주는 롬복 어노테이션

    @RequiredArgsConstructor : final이 붙거나 @NotNull 이 붙은 필드의 생성자를 자동 생성해주는 롬복 어노테이션

     

    3레이어 관련

    @RestController : 컨트롤러를 REST API 처리하는 컨트롤러로 정의하는 어노테이션. @Controller + @ResponseBody의 조합과 같은 역할을 수행
    @Service :
    비즈니스 로직을 수행하는 서비스 클래스를 정의하는 어노테이션
    @Repository :
    데이터베이스와 연동하여 데이터를 처리하는 DAO 클래스를 정의하는 어노테이션
    예외처리를 자동으로 처리하는 기능을 가지고 있음

     

    HTTP 요청 관련

    @RequestMapping : 요청 URL과 매핑되는 메서드를 지정할 때 사용하는 어노테이션. HTTP 메서드(GET, POST 등)와 URL 패턴을 지정할 수 있다.
    @GetMapping : HTTP GET 요청에 대한 처리를 지정할 때 사용하는 어노테이션. @RequestMapping(method=RequestMethod.GET)의 축약형.
    @PostMapping : HTTP POST 요청에 대한 처리를 지정할 때 사용하는 어노테이션. @RequestMapping(method=RequestMethod.POST)의 축약형.
    @PutMapping : HTTP PUT 요청에 대한 처리를 지정할 때 사용하는 어노테이션. @RequestMapping(method=RequestMethod.PUT)의 축약형.
    @DeleteMapping : HTTP DELETE 요청에 대한 처리를 지정할 때 사용하는 어노테이션. @RequestMapping(method=RequestMethod.DELETE)의 축약형.
    @PatchMapping : HTTP PATCH 요청에 대한 처리를 지정할 때 사용하는 어노테이션. @RequestMapping(method=RequestMethod.PATCH)의 축약형.

     

    객체 관련

    @Component : 스프링 프레임워크에서 객체를 생성하고 관리하는 빈(Bean)으로 등록할 수 있도록 해주는 어노테이션. 일반적으로 개발자가 작성한 클래스를 스프링에서 관리할 수 있도록 등록할 때 사용

    @Bean : Spring Framework에서 컨테이너에게 특정 객체를 생성하도록 지시하는 메타데이터 어노테이션. 보통 @Configuration 어노테이션이 적용된 클래스에서 @Bean 어노테이션을 사용하여 Bean 객체를 생성. 즉, @Bean 어노테이션은 개발자가 직접 객체를 생성하고 컨테이너에 등록하는 것이 아니라, Spring Framework에게 객체 생성을 요청하는 역할을 한다. 이를 통해 컨테이너에서 필요한 Bean 객체를 관리하고, 의존성 주입(DI) 등의 기능을 사용할 수 있다.

     

    @Getter : 클래스의 필드에 대한 getter 메서드를 자동으로 생성

    @Setter : setter 메서드를 자동으로 생성
    @Data : @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor를 모두 포함하는 Lombok 어노테이션. @Data는 클래스의 필드를 private으로 선언하고, getter와 setter 메서드를 자동으로 생성하며, equals(), hashCode(), toString() 메서드를 자동으로 구현한다.

     

    Entity 관련

    @Entity : JPA(Java Persistence API)에서 데이터베이스 테이블과 매핑되는 자바 객체를 정의할 때 사용하는 어노테이션. @Entity 어노테이션을 클래스에 붙이면 해당 클래스는 JPA 엔티티 클래스가 된다. 엔티티 클래스는 데이터베이스 테이블의 레코드에 대응되는 자바 객체이다.
    @Id : JPA에서 엔티티의 주요 식별자(primary key)를 지정할 때 사용하는 어노테이션. 엔티티 클래스에서 하나 이상의 필드를 @Id 어노테이션으로 지정해줄 수 있다.
    @Column : 엔티티 클래스의 필드와 데이터베이스 테이블의 컬럼을 매핑할 때 사용하는 어노테이션. 필드명과 컬럼명이 서로 다를 경우 name 속성을 사용해서 매핑할 컬럼명을 지정할 수 있다. 그 외에도 데이터 타입, 길이, null 허용 여부 등을 설정할 수 있는 속성이 있다.
    @GeneratedValue : JPA에서 Entity 클래스의 ID를 자동으로 생성하기 위해 사용되는 어노테이션. 보통 Entity 클래스에서 @Id 어노테이션과 함께 사용되며, ID 값이 자동으로 생성되어야 할 때 주로 쓰인다. @GeneratedValue는 여러가지 전략(strategy)을 제공한다.
        - GenerationType.AUTO: 데이터베이스에 따라 자동으로 선택됩니다.
        - GenerationType.IDENTITY: MySQL, PostgreSQL, SQL Server, DB2 등에서 사용됩니다.
        - GenerationType.SEQUENCE: Oracle, PostgreSQL, DB2, H2 등에서 사용됩니다.
        - GenerationType.TABLE: 키 생성 전용 테이블을 사용합니다.
    기본 전략은 GenerationType.AUTO이다. 예를 들어, MySQL의 경우 GenerationType.IDENTITY를, Oracle의 경우 GenerationType.SEQUENCE를 선택하면 된다.

    @Entity
    public class User {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        //...
    }

    위 코드에서는 GenerationType.IDENTITY 전략을 사용하여 ID 값을 자동 생성. 이렇게 생성된 ID 값은 데이터베이스의 AUTO_INCREMENT 컬럼이나 IDENTITY 컬럼과 같이 자동 증가하는 컬럼에 매핑된다.

    @Comment : Javadoc에 비해 좀 더 짧은 형태의 주석을 작성할 수 있는 어노테이션. 주로 필드나 메소드 위에 작성해서 해당 요소에 대한 설명을 간략하게 작성한다. 이 어노테이션은 자바의 주석인 //나 /* */와 같은 형태로 작성하는 것이 아니라, @Comment("주석 내용")와 같이 사용한다.
    @JsonProperty : Jackson 라이브러리에서 JSON 데이터의 Key와 매핑할 객체의 필드명을 지정하는 어노테이션.
    Java 객체를 JSON 데이터로 변환할 때 필드명과 Key가 다른 경우에 사용다.
    @Enumerated : @Enumerated는 열거형(Enum) 타입의 필드에 사용되는 어노테이션입니다. JPA에서 Enum 타입 필드를 매핑할 때 사용된다. 이 어노테이션을 사용하면 해당 필드가 DB에서는 어떤 형태로 저장될 것인지를 지정할 수 있다.
    EnumType.STRING과 EnumType.ORDINAL 두 가지 옵션이 있으며, 각각 문자열로 저장할지, 숫자로 저장할지를 결정한다.

     

    스웨거 관련 어노테이션

    관련 항목은 일일히 나열하지 않고 아래 예시코드로 정리한다. @Tag, @Operation 안에 사용된 어노테이션들이 이에 해당한다.

    /**
     * LDAP 인증을 받아 로그인 처리
     */
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/auth")
    @Tag(name = "로그인 controller", description = "LDAP 인증을 이용하여 사용자 로그인을 처리한다.")
    @Slf4j
    public class AuthController {
      private final AuthService authService;
    
      @Operation(summary = "Login method", description = "사용자의 ID와 PW를 받아 응답한다.")
      @ApiResponses(value = {
          @ApiResponse(responseCode = "200", description = "successful operation",
          content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = UserAuthLoginResponse.class))))
      })
      @PostMapping("/login")
      public ResponseEntity login(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "userId, password를 받아온다.") @RequestBody UserAuthLoginRequest request) {
        log.info("AuthController.login(/auth/login)=============================================");
        UserAuthLoginResponse response = authService.login(request);
    
        return ResponseEntity.status(HttpStatus.OK).body(response);
      }
    }

    기타

    @EnableWebSecurity : 어노테이션을 달면SpringSecurityFilterChain이 자동으로 포함. WebSecurityConfigurerAdapter를 상속한 클래스에 사용한다.

    @Slf4j : 롬복 어노테이션 중 하나로, 자동으로 private static final Logger log = LoggerFactory.getLogger(클래스명.class); 코드를 생성, 이를 통해 간단하게 로깅 코드를 작성할 수 있으며, 클래스 내에서 log.debug("debug 로그");, log.info("info 로그");, log.error("error 로그"); 등의 로그를 출력할 수 있다.

     

    람다식(lambda expression)

    자바 8부터 추가된 기능으로, 함수형 프로그래밍을 지원하기 위해 등장한 기능이다. 람다식은 간단히 말하면 익명 함수(anonymous function)의 형태로 표현된다.

    람다식은 함수형 인터페이스(functional interface)를 통해 사용된다. 함수형 인터페이스란 하나의 추상 메소드를 갖는 인터페이스를 말한다. 람다식은 이 추상 메소드의 구현부를 단순화하여 표현할 수 있다.

    람다식의 기본 문법은 다음과 같다.

    (parameter) -> { body }

     

    - (parameter) : 매개변수를 나타냅니다. 매개변수가 없을 경우 빈 괄호 ()를 사용합니다.
    - -> : 람다식의 표현식입니다.
    - { body } : 람다식의 몸체(body)를 나타냅니다.

     

    예를 들어, 다음은 정수형 리스트에서 짝수만 필터링하여 출력하는 코드입니다.

    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
    numbers.stream()
           .filter(num -> num % 2 == 0)
           .forEach(System.out::println);

    위 코드에서 스트림(Stream)은 자바 8부터 추가된 컬렉션(배열 포함)의 저장 요소를 하나씩 참조해서 람다식(함수적스타일)으로 처리할 수 있도록 해주는 반복자이다. Stream 내부 반복자는 컬렉션 내부에서 요소들을 반복시키고, 개발자는 요소당 처리해야 할 코드만 제공하는 코드 패턴을 말한다. 개발자는 요소당 처리해야 할 코드만 제공하는 코드 패턴을 말한다. 그리고 스트림은 '중간처리'와 '최종처리'를 할 수 있는데, 예제 코드에서는 filter()이라는 중간처리를 적용한 것이다.

     note : 중간처리 메소드와 최종처리 메소드를 쉽게 구분하는 방법은 리턴 타입에서 처이점이 있다. 리턴타입이 스트림이면 중간처리 메소드이고, 기본 타입이거나 OptionalXXX라면 최종처리 메소드이다.

     

    stream()을 통해 filter 메소드의 인자로 전달되는 람다식 num -> num % 2 == 0은 매개변수로 받은 num이 2로 나누어 떨어지는지를 검사하여 true 또는 false를 반환합니다. 이를 통해 짝수만 필터링할 수 있게 됩니다.

     

    또, "System.out::println"은 Java 8부터 도입된 메서드 참조(Method reference)의 형태 중 하나입니다. 메서드 참조는 메서드를 람다식으로 간략하게 표현할 수 있는 방법입니다.
    System.out은 표준 출력 스트림을 나타내는 객체입니다. println은 System.out 객체의 메서드 중 하나로, 인자로 전달된 문자열을 출력하는 기능을 합니다. System.out::println은 System.out 객체의 println 메서드를 람다식으로 대체하는 것으로, 이를 호출하면 println 메서드가 실행됩니다.

    람다식을 사용하면 함수형 프로그래밍을 보다 쉽고 편리하게 구현할 수 있습니다. 또한, 람다식을 사용하면 코드의 가독성을 높일 수 있으며, 반복되는 코드의 양을 줄일 수 있습니다.