HTTP와 로그인 인증 및 파일 업로드

스프링 웹 애플리케이션에서 HTTP 통신의 핵심 개념인 리다이렉트와 포워드의 차이점, PRG 패턴을 활용한 중복 제출 방지, 로그인 세션/토큰 인증, 파일 업로드/다운로드 구현 방법 및 REST API 설계 원칙을 상세히 설명하는 가이드입니다.



HTTP 중요지식

redirect vs forward
  • 비유 예시(고객:클라, 상담원:서버, 123or124:URL)
    • 첫번째 사례(redirect)
      1. 고객이 고객센터로 상담원에게 123번으로 전화를 건다.
      2. 상담원은 고객에게 다음과 같이 이야기한다. “고객님 해당 문의사항은 124번으로 다시 문의 해주시겠어요?”
      3. 고객은 다시 124번으로 문의해서 일을 처리한다.
    • 두번째 사례(forward)
      1. 고객이 고객센터로 상담원에게 123번으로 전화를 건다.
      2. 상담원은 해당 문의사항에 대해 잘 알지 못해서 옆의 다른 상담원에게 해당 문의사항에 답을 얻는다.
      3. 상담원은 고객에게 문의사항을 처리해준다.
  • redirect의 경우 최초 요청을 받은 URL1에서 클라이언트에 redirect할 URL2를 리턴하고, 클라이언트에게 전혀 새로운 요청을 생성하여 URL2에 다시 요청을 보낸다. 따라서 처음 보냈던 최초의 요청정보는 더이상 유효하지 않게된다.
    • web container는 redirect 명령이 들어오면 웹 브라우저에게 다른 페이지로 이동하라는 명령을 내린다.(첫번째 사례의 경우, 고객은 전화를 끊고 124번으로 다시 전화를 건다)
      • 다른 web container에 있는 주소로 이동 가능(ex: 123 -> 124)
    • 새로운 페이지에서는 request, response객체가 새롭게 생성된다.(123번에서 고객이 요청했던 문의사항은 사라지고 124번으로 다시 걸어서 요청한 문의사항을 다시 말해야한다.)
  • forwar방식은 다음 이동한 URL로 요청정보를 그대로 전달한다. 말 그대로 forward(건네주기)하는 것이다. 그렇기 때문에 사용자가 최초로 요청한 요청정보는 다음 URL에서도 유효하다.
    • web container 차원(서버단)에서의 페이지 이동, 실제로 웹 브라우저는 다른 페이지로 이동했는지 알 수 없다.(두번째 사례의 경우, 고객은 상담원이 누구한테 물어봤는지 알 수 없다.)
      • 동일한 web container에 있는 페이지로만 이동이 가능하다.
    • 현재 실행중인 페이지와 forward에 의해 호출될 페이지는 request, response 객체를 공유한다.(고객이 요청한 문의사항은 고객이 전화를 끊을 때까지 유효하다.)
  • 차이점
    • URL의 변화여부: redirect(변화O), forward(변화X)
    • 객체의 재사용여부 : redirect(재사용X), forward(재사용O)
  • 언제 사용하는게 바람직한가?
    • 시스템(session, DB)에 변화가 생기는 요청(로그인, 회원가입, 글쓰기)의 경우 redirect방식으로 응답하는 것이 바람직
    • 시스템에 변화가 생기지 않는 단순조회(리스트보기, 검색)의 경우 forward방식으로 응답하는 것이 바람직


PRG Post/Redirect/Get - POST를 무한한 재요청 문제 해결 패턴
  • 웹 브라우저의 새로고침은 마지막 서버에 전송한 데이터를 다시 전송한다.
  • 따라서 POST 적용후 새로고침을 하면 계속 POST 보내는 문제가 발생하므로 이를 Redirect를 통해서 GET으로 요청하는 방식으로 해결할 수 있다.
    • Redirect를 사용해야지만 POST 보내는 URL을 벗어나기 때문!!
  • RedirectAttributes 추천 -> Redirect는 원래 연결 끊어지니까 자원 재전송 필요시!!
    • Redirect 할때 Model처럼 파라미터를 추가해서 간편히 넘겨줄 수 있고, URL 인코딩 문제에서 자유롭다!
      • "redirect:/basic/items/" + item.getId() 는 인코딩 문제가 발생할 수 있는데,
      • "redirect:/basic/items/{itemId}" 는 인코딩 문제에서 자유롭다.
    • 특히 Status 정보를 파라미터로 넘김으로써 th:if 문법으로 “저장완료” 표시도 나타내는데 많이 사용한다.
      // 저장 성공 상태를 파라미터로 전달 (파라미터로 URL에 추가됨)
      // ex: URL...?status=success
      redirectAttributes.addAttribute("status", "success");
      <!-- "저장 완료" 메시지 표시 -->
      <div th:if="${status == 'success'}">
          <p>저장 완료!</p>
      </div>
      
    • 따라서 Redirect 할때는 RedirectAttributes.addAttribute() 추천, html 반환(렌더링) 할때는 Model.addAttribute() 추천
      • redirectAttributes.addAttribute 는 파라미터로 전송되므로 @RequestParam(defaultValue = “”) 등으로 간편히 사용가능!!
      • 참고: redirectAttributes.addFlashAttribute 의 경우 불가능!! 파라미터 전송이 아니고 딱 한번 세션에 저장해서 전달하는 방식이라서!! 파라미터에 담아서 전송 후 바로 제거한다고도 하더라


웹 브라우저에서의 redirect 특징


웹 브라우저는 3xx 응답의 결과에 Location 헤더가 있으면, Location 위치로 자동 이동한다.
Image

  1. url : /event로 get을 요청
  2. 하지만 url이 /event에서 /new-event로 바뀌어서 서버가 다시 Location으로 응답
  3. 자동 리다이렉트로 url 변경, 클라이언트 단에서 스스로 자동 리다이렉트
  4. 다시 /new-event로 get 요청 -> 처음부터 다시 요청한다.
  5. 정상적으로 성공했기에 200 OK 응답


Content-Type 헤더 기반 Media Type 과 Accept 헤더 기반 Media Type

  • text/html, application/json 같은 content-type 을 의미
  • 요청때나 응답할때나 body를 사용할때는 필수로 존재 및 서로 맞게 요청해야 함


참고: GET은 쿼리파라미터(url) 방식, POST는 body에 데이터 방식, from 데이터는 name,value 방식으로써 데이터 바인딩하기 수월

API URI 설계에는 “리소스”가 중요하다. “행위”는 메서드(get, post 등)로 구분하자.
단, 실무에서는 행위(동사)를 URI에 작성해야 할 때도 좀 있는데 이를 “컨트롤 URI”라 부른다.

HTTP로 클라이언트 -> 서버 데이터 전송 4가지 상황 (참고: 전달 방식은 크게 2가지-쿼리 파라미터, 메시지 바디)


  1. 정적 데이터 조회 -> 쿼리 파라미터 미사용
    • 이미지, 정적 텍스트 문서
  2. 동적 데이터 조회 -> 쿼리 파라미터 사용
    • 주로 검색, 게시판 목록에서 정렬 필터(검색어)
  3. HTML Form을 통한 데이터 전송 -> GET은 쿼리 파라미터로, POST는 메시지 바디로 자동 작성!
    • 회원 가입, 상품 주문, 데이터 변경
    • Content-Type: application/x-www-form-urlencoded
      • form의 내용을 메시지 바디를 통해서 전송(POST로 해보면 나옴)
      • 전송 데이터를 url encoding 처리
        • 예) abc김 -> abc%EA%B9%80
    • Content-Type: multipart/form-data
      • 파일 업로드 같은 바이너리 데이터 전송시 사용
      • 다른 종류의 여러 파일과 폼의 내용 함께 전송 가능(그래서 이름이 multipart)
  4. HTTP API를 통한 데이터 전송 -> 이하 동문
    • 회원 가입, 상품 주문, 데이터 변경
    • 서버 to 서버, 앱 클라이언트, 웹 클라이언트(Ajax)
    • Content-Type: application/json
HTTP API 설계 2가지로 "컬렉션 기반"과 "스토어 기반" (+HTML FORM)


크게 2가지로 “컬렉션 기반”과 “스토어 기반”으로 나눠볼 수 있다. (대부분 컬렉션 방식 씀)
HTML FORM 방식은 애초에 GET, POST만 지원한다. (PUT기반 아니니까 컬렉션이지)
1. API 설계 - 컬렉션 기반(POST)

  • 회원 목록 /members -> GET
    회원 등록 /members -> POST
    회원 조회 /members/{id} -> GET
    회원 수정 /members/{id} -> PATCH, PUT, POST
    회원 삭제 /members/{id} -> DELETE
    • 회원 수정에 개념적으론 PATCH 사용이 제일 좋다.
  • 클라이언트는 등록될 리소스의 URI를 모른다.
    • 회원 등록 /members -> POST
    • POST /members
  • 서버가 새로 등록된 리소스 URI를 생성해준다.
    • HTTP/1.1 201 Created
      Location: /members/100
  • 컬렉션(Collection)
    • 서버가 관리하는 리소스 디렉토리
    • 서버가 리소스의 URI를 생성하고 관리
    • 여기서 컬렉션은 /members


2. API 설계 - 스토어 기반(PUT)

  • 파일 목록 /files -> GET
    파일 조회 /files/{filename} -> GET
    파일 등록 /files/{filename} -> PUT
    파일 삭제 /files/{filename} -> DELETE
    파일 대량 등록 /files -> POST
  • 클라이언트가 리소스 URI를 알고 있어야 한다.
    • 파일 등록 /files/{filename} -> PUT
    • PUT /files/star.jpg
  • 클라이언트가 직접 리소스의 URI를 지정한다.
  • 스토어(Store)
    • 클라이언트가 관리하는 리소스 저장소
    • 클라이언트가 리소스의 URI를 알고 관리
    • 여기서 스토어는 /files


3. HTML FORM 사용 - GET, POST

  • 회원 목록 /members -> GET
    회원 등록 폼 /members/new -> GET
    회원 등록 /members/new, /members -> POST
    회원 조회 /members/{id} -> GET
    회원 수정 폼 /members/{id}/edit -> GET
    회원 수정 /members/{id}/edit, /members/{id} -> POST
    회원 삭제 /members/{id}/delete -> POST
    • URI 안바뀌는 /members/new 방식을 좀 더 선호하고, /members로 등록하는 사람도 있음.
    • GET, POST만 지원하니까 이런 제약을 해결하고자 동사를 사용한 컨트롤 URI 방식도 많이 사용.
      참고 문서: https://restfulapi.net/resource-naming
  • 문서(document)
    • 단일 개념(파일 하나, 객체 인스턴스, 데이터베이스 row)
    • 예) /members/100, /files/star.jpg
  • 컬렉션(collection) -> 주로 이 방식만 접할거임.ㅇㅇ.
    • 서버가 관리하는 리소스 디렉터리
    • 서버가 리소스의 URI를 생성하고 관리
    • 예) /members
  • 스토어(store)
    • 클라이언트가 관리하는 자원 저장소
    • 클라이언트가 리소스의 URI를 알고 관리
    • 예) /files
  • 컨트롤러(controller), 컨트롤 URI
    • 문서, 컬렉션, 스토어로 해결하기 어려운 추가 프로세스 실행
    • 동사를 직접 사용
    • 예) /members/{id}/delete<
</div>



“로그인 인증 방식”

참고: DB에 PW를 보통 암호화해서 넣어두는데, 로그인 인증 때 ID만 비교해서 값을 찾아오고 ID 있으면 같이 가져온 PW를 복호화해서 비교하는 순으로 하는게 효율적이고 빠르다는 것.


(1) Session(서버 메모리) 방식

AOP챕터의 -ArgumentResolver 예시 코드의 로그인 로직 참고

동작흐름: HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러

interceptor 먼저 수행 후 -> argumentresolver 수행 순서


(2) JWT(토큰) -> Spring Security 사용 (코드포함)

Spring Security??
  • Spring 내에서 Filter 를 기반으로 동작한다. 즉, Http RequestDispatcher Servlet 에 도착하기 이전, Spring Security 가 제공하는 기능을 활용하여, 인증 혹은 인가에 대한 사전 검증을 진행한다.
  • 참고: HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
  • 주요기능 -> 참고 문서
    1. UserDetails 인터페이스 -> 인증, 인가(권한부여) 기능 제공
      • 실무 관례: UserDetails 상속받는 CustomUserDetails 클래스 만들어 사용
      • getAuthorities() : 계정의 권한 목록을 리턴한다.
      • getPassword() : 계정의 비밀번호를 리턴한다.
      • getUsername() : 계정의 고유한 값을 리턴한다. (중복 없는 이메일, PK)
      • isAccountNonExpired() : 계정의 만료 여부를 리턴한다.
      • isAccountNonLocked() : 계정의 잠김 여부를 리턴한다.
      • isEnabled() : 계정의 활성화 여부를 리턴한다.
      • 기본 제공 메서드 말고, 커스텀하여 추가 메서드 예시? getEmail() 이런거!
    2. UserDetailsService 인터페이스 -> 사용자 정보 조회 위한 서비스 단
      • loadUserByUsername(String token) : 사용자의 토큰에서 claim 정보를 기반으로, 사용자를 조회하여, 조회된 정보를 기반으로 UserDetails 인스턴스를 생성한다.
        • claim 정보란 JWT의 사용자 정보인 키-값 쌍 데이터!
    3. Authentication 인터페이스 -> 사용자 정보와 권한 담음
      • 동작 과정을 보면 알지만 UsernamePasswordAuthenticationToken, UserDetails를 다 가지게 된다.
    4. AuthenticationProvider 인터페이스 -> authenticate()supports() 메서드를 오버라이드
      • authenticate() : 실제 인증 로직 구현이 이루어지는 메서드이고, UsernamePasswordAuthenticationToken 로 토큰을 만들며, 이 토큰 클래스는 UserDetails 클래스를 기반으로 Authentication 객체로 반환 한다.
      • supports() : 현재 Provider 클래스가 특정 타입의 Authentication 객체를 지원하는 여부를 알려주는 메서드이다. 예로, 인증객체(=authentication)가 특정 인증 타입(=UsernamePasswordAuthenticationToken)을 지원하는지 판별
        • 예시 코드: return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    5. AuthenticationManager -> AuthenticationProvider 구현체를 관리
      • 여러 Provider 구현체를 AuthenticationManager 에 담아 두면 상황에 따라 필요한 Provider 구현체를 불러오는 것이 가능하다.
    6. SecurityContext -> 인증 정보를 서버 내에서 보관 및 @AuthenticationPrincipal을 사용하여 인증 정보를 가져올 수 있다
      • 동작 과정을 보면 알지만 Authentication를 포함하는 구조다.
    7. GrandAuthority -> 현재 사용자가 가진 권한을 의미 (권한 확인에 사용)
      • 예로 ROLE_USER, ROLE_ADMIN 등을 정의하거나 체크할 수 있다.
Spring Security 처리과정


image

  1. 사용자가 로그인 정보와 함께 인증 요청을 진행한다.
  2. AuthenticationFilter 가 해당 요청에 대하여 UsernamePasswordToken 객체를 생성한다.
  3. AuthenticationManager 에, 해당 UsernamePassowordToken 객체를 전달한다.
  4. AuthetincationManager 는 전달된 UsernamePassowordToken 객체의 인증을 지원하는 AuthenticationProvider 를 찾아, 인증을 요구한다.
  5. AutheticationProviderauthenticate() 메서드를 호출하여, 인증 로직이 수행되도록 한다. 인증 로직의 경우 서비스마다 차이는 있겠으나, 공통적인 수행 방식은 다음과 같다.
    • UserDetailsService 에서 해당 token 값의 claims 정보를 기반으로 해당하는 사용자를 찾는다.
    • 인증 과정을 수행한 후, 성공적으로 인증이 되었다면 해당 사용자 객체 정보를 기반으로 UserDetails 객체를 생성하여 반환한다.
    • 인증이 완료된 UserDetails 객체를 기반으로, 사용자 정보 및 권한 정보가 담긴 Authentication 객체를 생성한다.
    • Authentication 객체를 SecurityContext 에 저장한다. 향후 Controller 에서 사용자 정보나 권한 정보가 필요한 경우, @AuthenticationPrincipal 등을 사용하여, 필요한 객체 정보를 불러올 수 있다.
Spring Security 장점
  • 간편한 인증 및 인가: 기본적인 인증 및 인가 기능이 내장
  • 자동 세션 관리: 간단한 코드 설정으로 로그인 시 자동으로 세션을 관리하고, 로그아웃 시 세션을 무효화
  • CSRF 보호: 기본적으로 CSRF 공격에 대한 보호 기능이 제공
    • CSRF(크로스 사이트 요청 위조, Cross-Site Request Forgery) 공격은 공격자가 사용자의 인증된 세션을 이용해, 사용자가 의도하지 않은 요청을 전송하게 만드는 공격 방식
  • 비밀번호 암호화: PasswordEncoder로 간단히 비밀번호 암호화

예전버전 -> 현재버전 바뀐점들 많다!! 공문 참고

  • 예전 버전은 WebSecurityConfigurerAdapterDeprecated 되었으니 SecurityFilterChainBean으로 등록해서 사용하는 방식을 써라.

  • mvcMatchers가 없어지고 requestMatchers 로 바뀜 -> 내부에 new MvcRequestMatcher() 또는 new AntPathRequestMatcher() 조합을 사용해야지만 함!

  • authorizeHttpRequest가 없어지고 authorizeHttpRequests 로 바뀜

  • 예전이랑 코드가 많이 바꼈기 때문에 공식문서 꼭 잘 참고! (테스트 코드도 제공!)


LEPL플젝에 적용해보기 (위 Spring Security 처리과정 그림 꼭 참고)
사용파일: ApiConfigV2.java, MemberDetail.java, MemberDetailService.java, CustomAuthenticationFilter.java, CustomAuthenticationToken.java, CustomAuthenticationProvider.java, CustomAuthenticationSuccessHandler.java, CustomAuthenticationFailureHandler.java

기존 세션방식에서 직접 구현한 Login, LoginMemberArgumentResolver, MemberCheckInterceptor 클래스 같은 것 들이 필요없어 짐! 대신 웹이아닌 API이다 보니 커스텀할 클래스도 좀 더 많긴 하다.

implementation 'org.springframework.boot:spring-boot-starter-security' 의존성 추가 하면 바로 인증 필요로 접속 막힘 (기본값 username:user, password:콘솔창에 UUID)

특히 LEPL에 적용하려면… 웹 말고 API 방식 사용! (JWT)

  • uid를 username로, password는 ""로 빈값으로 통일함!
    참고: 스프링 시큐리티는 기본적으로 Form 인증 방식을 채택 -> 우리 플젝은 API 이므로 json데이터 매핑위해 커스텀 필수! (그래서 추가 커스텀 파일 굉장히 많은 것!)

    • 물론,,,,,, 커스텀Filter를 Bean등록하는데 오류 터지는 중 ㅠㅠ
      AOP프록시에서 문제가 있었고, scurity패키지 부분은 제외해서 해결!

    • 컨틀롤러에 login, logout 부분은 없애야 할걸?! 이미 커스텀 필터로 다 했고, 성공핸들러와 실패핸들러도 직접 커스텀 다 했으니까!! 반환도 직접 다 했고!

      • 실제로, 로그인 부분은 애초에 컨트롤러 있어도 그쪽은 타질 않음. 필터에서 이미 뺏어서 다 처리했기 때문!!

      • 로그아웃도 추가하면 애초에 필터에서 컨트롤러가기전에 먼저 뺏는거라 잘 처리됨!

        • Logout 기능을 활성화하면 LogoutFilter 가 생김 (코드1줄!)

        • 해당 필터 내부에는 이미 로그아웃 핸들러도 존재 -> 아래 다 자동!~!!

          1. 세션 무효화 핸들러

          2. 인증토큰, SecurityContext 삭제 핸들러

          3. 쿠키정보 삭제 핸들러

    • 마지막으로 @Login 애노테이션으로 memberId (세션에 저장된) 를 가져오는것처럼
      @AuthenticationPrincipal 로 바로 가져와 사용 가능!

  • API 기반의 애플리케이션에서 Spring Security로 로그인과 로그아웃을 처리할 때는 formLogin이나 logout 설정을 사용하지 않고, 주로 JWTOAuth2 같은 토큰 기반 인증 방식을 사용

    • 웹의 경우: formLogin(로그인페이지), loginProcessingUrl(로그인 처리 페이지), defaultSuccessUrl(성공시 이동페이지) 등등 이런식으로 구성!!! -> 참고 문서
    • API는 JWT, OAuth2 같은 토큰을 활용하므로 방식이 다름 -> 참고 문서
      • API 기반 개발했고, 자세한 내용은 전체 코드의 주석+참고 문서+Spring Security 처리과정 그림 을 보면 이해할 수 있을것이다!
    전체 코드


    build.gradle

    //Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation "org.springframework.security:spring-security-web"
    implementation "org.springframework.security:spring-security-config"
    

    ApiConfigV2.java

    /**
     * Spring Security 쓸거면??
     * 1. build.gradle 에 주석해제해서 라이브러리 다운
     * 2. security 패키지 파일 주석 전부 해제 (개노가다;;)
     */
    @Configuration // 설정 파일임을 알림
    @EnableWebSecurity // Spring Security
    @RequiredArgsConstructor
    @Slf4j
    public class ApiConfigV2 {
      private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
      private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;
      private final AuthenticationConfiguration authenticationConfiguration;
      private final HandlerMappingIntrospector introspector;
      private final MemberDetailService detailService;
      /**
       * Spring Security의 앞단 설정을 할수 있다.
       * debug, firewall, ignore등의 설정이 가능
       * 단 여기서 resource에 대한 모든 접근을 허용하는 설정할수도 있는데
       * 그럴경우 SpringSecuity에서 접근을 통제하는 설정은 무시해버린다.
       */
      @Bean
      public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> {
          web
              .ignoring()
              .requestMatchers(new AntPathRequestMatcher("/h2-console/**")
              );
        };
      }
    //
      /**
       * Spring Security 기능 설정을 할수 있다.
       * 특정 리소스에 접근하지 못하게 하거나 반대로 로그인, 회원가입 페이지외에 인증정보가 있어야
       * 접근할 수 있도록 설정할 수 있다.
       * 특정 리소스의 접근허용 또는 특정 권한 요구,로그인, 로그아웃, 로그인,로그아웃 성공시 Event
       * 등의 설정이 가능하다.
       */
      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // csrf는 브라우저 없이 API 개발은 jwt같은 인증방식을 사용해서 굳이 필요가 없다고 한다.
        // 요청에 대한 설정
        // permitAll시 해당 url에 대한 인증정보를 요구하지 않는다.
        // authenticated시 해당 url에는 인증 정보를 요구한다.(로그인 필요)
        // hasAnyRole시 해당 url에는 특정 권한 정보를 요구한다.
        // resources에 대해 접근혀용을 해야지 브라우저에서 로그인없이 js파일이나 css파일에 접근할 수 있다.
        http
            .csrf(csrf -> csrf
                .ignoringRequestMatchers(new AntPathRequestMatcher("/**"))
            ) //전체 경로에 csrf 비활성화
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers(new MvcRequestMatcher(introspector,"/api/v1/members/**")).permitAll()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // 정적 자원
                .anyRequest().authenticated()
            )
            .addFilterBefore(ajaxAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class) // 필터 가로채기!
            .logout(logout -> logout
                .logoutUrl("/api/v1/members/logout")
                .logoutSuccessHandler((request, response, authentication) -> {
                  response.setStatus(HttpStatus.OK.value());
                  response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                  response.getWriter().print("로그아웃 성공");
                }) // 로그아웃 성공 핸들러 (간단히 추가)
            );
        return http.build();
      }
    //
      @Bean
      public CustomAuthenticationFilter ajaxAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter();
        customAuthenticationFilter.setAuthenticationManager(authenticationManager()); //manager 등록
        customAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        customAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
    //
        //Form Login 방식은 기본적으로 DelegatingSecurityContextRepository 가 설정되어 있어서 REST API 방식을 위해 아래 추가
        //RequestAttributeSecurityContextRepository 는 세션을 저장 하지 않기 때문에 로그인 후 API 요청시 Anonymous 토큰이 생성되게 됩니다.
        //HttpSessionSecurityContextRepository 는 빈 세션을 만들 뿐! (어차피 토큰 사용할거라 빈 상태로 놔둘거임)
        customAuthenticationFilter.setSecurityContextRepository(
            new DelegatingSecurityContextRepository(
                new RequestAttributeSecurityContextRepository(),
                new HttpSessionSecurityContextRepository()
            ));
        System.out.println("test5");
        return customAuthenticationFilter;
      }
    //
      @Bean
      public AuthenticationManager authenticationManager() throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
      }
    //
      @Bean
      public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder(); //provider 에서 사용 위해 빈 등록
      }
    }
    

    MemberDetail.java

    @Data
    public class MemberDetail implements UserDetails {
      private Member member;
      public MemberDetail(Member member) {
        this.member = member;
      }
      @Override
      public Collection<? extends GrantedAuthority> getAuthorities() {
        // 특별한 권한 시스템을 사용하지 않을경우 -> role
        return Collections.EMPTY_LIST;
      }
      @Override
      public String getPassword() {
        return "";
      }
      @Override
      public String getUsername() {
        return member.getUid();
      }
      // 계정 만료여부 제공
      // 특별히 사용을 안할시 항상 true를 반환하도록 처리
      @Override
      public boolean isAccountNonExpired() {
        return true;
      }
      // 계정 비활성화 여부 제공
      // 특별히 사용 안할시 항상 true를 반환하도록 처리
      @Override
      public boolean isAccountNonLocked() {
        return true;
      }
      // 계정 인증 정보를 항상 저장할지에 대한 여부
      // true 처리할시 모든 인증정보를 만료시키지 않기에 주의해야한다.
      @Override
      public boolean isCredentialsNonExpired() {
        return false;
      }
      // 계정의 활성화 여부
      // 딱히 사용안할시 항상 true를 반환하도록 처리
      @Override
      public boolean isEnabled() {
        return true;
      }
    }
    

    MemberDetailService.java

    @Service
    @RequiredArgsConstructor
    public class MemberDetailService implements UserDetailsService {
      private final MemberRepository repository;
      @Override
      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = repository.findByUid(username);
        if(member == null)
          throw new UsernameNotFoundException("계정을 찾을 수 없습니다.");
        return new MemberDetail(member);
      }
      /**
       * 위에서 비밀번호는 사용안하냐고 물을수 있다.
       * 보통 Database에 비밀번호를 암호화 해서 저장하므로 Database에서 해당 정보를 찾을때 ID값과 암호화된 비밀번호를
       * 비교해가면서 찾는것보다 먼저 ID값으로 먼저 찾고 비밀번호를 복호화해서 비교하는게 더 빠르고 정확하다.
       * 때문에 대부분 회원가입할때 ID 중복을 확인하는 이유가 ID값으로 찾았을때 여러개의 계정정보가 검색되면 어떤 계정으로 인증을 해야할지 알수없기 때문.
       */
    }
    

    CustomAuthenticationFilter.java

    public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
      private ObjectMapper objectMapper = new ObjectMapper();
      public CustomAuthenticationFilter() {
        // url과 일치하면 해당 필터가 가로채서 동작!
        super(new AntPathRequestMatcher("/api/v1/members/login"));
        System.out.println("test2");
      }
    //
      @Override
      public Authentication attemptAuthentication(HttpServletRequest request,
          HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        // POST 인지 확인
        System.out.println("test1");
        if (!"POST".equals(request.getMethod())) {
          throw new IllegalStateException("Authentication is not supported, no post");
        }
        // body를 login 정보에 매핑
        LoginDto loginDto = objectMapper.readValue(request.getReader(), LoginDto.class);
        // id, password cehck
        if (loginDto.getUid().isEmpty()) {
          throw new IllegalArgumentException("username or password is empty, no uid");
        }
        // 인증 되지 않은 토큰 생성 (나중에 인증)
        CustomAuthenticationToken token = new CustomAuthenticationToken(
            loginDto.getUid(),
            "" // uid만 사용하므로 password 생략
        );
        // manager 가 인증 처리하게 위임
        Authentication authentication = getAuthenticationManager().authenticate(token);
        return authentication;
      }
    //
      @Data
      public static class LoginDto {
        private String uid;
      }
    }
    

    CustomAuthenticationToken.java

    public class CustomAuthenticationToken extends AbstractAuthenticationToken {
      private static final Long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
      private final Object principal; //사용자 식별 정보(ex: username, id)
      private Object credentials; //사용자 인증 정보(ex: password, token)
      //인증 전 생성자
      public CustomAuthenticationToken(Object principal, Object credentials) {
        super(null); //인증전이라 role X
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false); //인증되지 않은 상태로 설정 (오버라이딩한 자식 메소드임)
      }
      //인증 후 생성자
      public CustomAuthenticationToken(Object principal, Object credentials,
          Collection<? extends GrantedAuthority> authorities) {
        super(authorities); //role 등록 -> 이 플젝은 role 미사용이긴 함!
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true); //인증된 상태로 설정 (부모)
      }
    //
      @Override
      public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        //애초에 true 로 설정할 일이 없어서 조건문 추가 위해 Override
        Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
      }
      @Override
      public void eraseCredentials() {
        super.eraseCredentials(); //인증 정보 지우기
        this.credentials = null;
      }
      @Override
      public Object getCredentials() {
        return this.credentials;
      }
      @Override
      public Object getPrincipal() {
        return this.principal;
      }
    }
    

    CustomAuthenticationProvider.java

    @Component
    @RequiredArgsConstructor
    public class CustomAuthenticationProvider implements AuthenticationProvider {
      private final MemberDetailService memberDetailService;
    //  private final PasswordEncoder passwordEncoder; // 원래 여기서 password 복화해 해서 비교!
    //
      @Override
      public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        MemberDetail entity = (MemberDetail) memberDetailService.loadUserByUsername(username);
        return new CustomAuthenticationToken(entity, null, entity.getAuthorities());
      }
      @Override
      public boolean supports(Class<?> authentication) {
        return authentication.equals(CustomAuthenticationToken.class);
      }
    }
    

    CustomAuthenticationSuccessHandler.java

    @Slf4j
    @Component
    public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
      private ObjectMapper objectMapper = new ObjectMapper();
      //Rest API 이기 때문에 redirect 처리를 하지 않고 response 에 바로 값
      @Override
      public void onAuthenticationSuccess(HttpServletRequest request,
          HttpServletResponse response,
          Authentication authentication) throws IOException {
        System.out.println("test3");
        MemberDetail member = (MemberDetail) authentication.getPrincipal();
    //
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    //    objectMapper.writeValue(response.getWriter(), member);
        objectMapper.writeValue(response.getWriter(), SUCCESS_LOGIN);
      }
    }
    

    CustomAuthenticationFailureHandler.java

    @Slf4j
    @Component
    public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
      private ObjectMapper objectMapper = new ObjectMapper();
      @Override
      public void onAuthenticationFailure(HttpServletRequest request,
          HttpServletResponse response,
          AuthenticationException exception) throws IOException {
        String errMsg = "Invalid Username or Password";
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    //
        if(exception instanceof BadCredentialsException) {
          errMsg = "Invalid Username or Password";
        } else if(exception instanceof DisabledException) {
          errMsg = "Locked";
        } else if(exception instanceof CredentialsExpiredException) {
          errMsg = "Expired password";
        }
        objectMapper.writeValue(response.getWriter(), errMsg);
      }
    }
    

    Controller.java

    /**
     * 일정 조회(3) - 모든 Lists(=하루단위 일정모음) 조회 -> 해당 회원꺼만
     */
    //아래 코드는 기존 argumentresolver 사용해서 @Login 으로 memberId 겟!
    @GetMapping(value = "/member/all")
    public ResponseEntity<List<ListsResDto>> findAllWithMemberTask(@Login Long memberId) {
      List<Lists> listsList = listsService.findAllWithMemberTask(memberId);
      if (listsList.isEmpty()) {
        return ResponseEntity.status(HttpStatus.NO_CONTENT).body(null);
      }
      List<ListsResDto> result = listsList.stream()
          .map(o -> new ListsResDto(o))
          .collect(Collectors.toList());
      return ResponseEntity.status(HttpStatus.OK).body(result);
    }
    //
    //아래 코드는 Spring Security 사용하여 @Login 대신 memberId를 가져온 코드
    @GetMapping(value = "/member/all")
    public ResponseEntity<List<ListsResDto>> findAllWithMemberTask(@AuthenticationPrincipal
        MemberDetail memberDetail) {
      Long memberId = memberDetail.getMember().getId();
      List<Lists> listsList = listsService.findAllWithMemberTask(memberId);
      if (listsList.isEmpty()) {
        return ResponseEntity.status(HttpStatus.NO_CONTENT).body(null);
      }
      List<ListsResDto> result = listsList.stream()
          .map(o -> new ListsResDto(o))
          .collect(Collectors.toList());
      return ResponseEntity.status(HttpStatus.OK).body(result);
    }
    



“파일 업로드, 다운로드” 기본 지식

  • 파일은 “스토리지” 저장, 경로와 이름 등 정보(EX: Item)는 “DB” 저장
  • Item - uploadFileName, storeFileName 는 필수 저장
    • uploadFileName(=업로드 파일명), storeFileName(=서버에 저장된 파일명) 둘 다 DB에 기록해놔야 함
    • 업로드 파일명들은 사람마다 중복될 수 있으며, 서버 파일명은 중복되면 안돼서 UUID 같은걸로 지정하기에 “둘 다 기록”
  • ItemForm - Item의 Dto 용으로 만들어서 Form 데이터를 받는 도메인을 만들어줘야 함
    • 여기선 MultipartFile 타입을 사용해 데이터 받을거라 Item 에선 할 수 없기에 만들어줌
  • FileStore.java
    • “스토리지” 에 저장하는 로직을 작성해서 “컨트롤러” 에서 사용
  • 컨트롤러에서..
    • @GetMapping("/images/{filename}") : <img> 태그로 이미지를 조회할 때 사용
      • UrlResource 로 이미지 파일을 읽어서 @ResponseBody 로 이미지 바이너리를 반환
      • 경로에 “file:” 을 넣어야 내부저장소 경로를 접근하는 것 (스토리지에 파일 있으니까!)
        • 이 부분을 통해 “경로 설정” 을 꼭 해줘야 정상 접근
    • @GetMapping("/attach/{itemId}") : 파일을 다운로드 할때 실행
      • “/attach/{itemId}” - <a> 태그 “href” 활용해 “파일명” 을 눌러서 접근하게 한 URL 경로
        • 파일 다운로드시 권한 체크같은 복잡한 상황까지 가정해서 이미지 id 를 요청하도록 함
      • 파일 다운이 되려면 반환할때 “헤더” 가 필수
      • 파일 다운로드시에는 고객이 업로드한 파일 이름으로 다운로드 하는게 좋다.
        • Content-Disposition 헤더에 attachment; filename="업로드 파일명"



댓글남기기