HTTP와 로그인 인증 및 파일 업로드
스프링 웹 애플리케이션에서 HTTP 통신의 핵심 개념인 리다이렉트와 포워드의 차이점, PRG 패턴을 활용한 중복 제출 방지, 로그인 세션/토큰 인증, 파일 업로드/다운로드 구현 방법 및 REST API 설계 원칙을 상세히 설명하는 가이드입니다.
HTTP 중요지식
redirect vs forward
-
비유 예시(고객:클라, 상담원:서버, 123or124:URL)
-
첫번째 사례(redirect)
- 고객이 고객센터로 상담원에게 123번으로 전화를 건다.
- 상담원은 고객에게 다음과 같이 이야기한다. “고객님 해당 문의사항은 124번으로 다시 문의 해주시겠어요?”
- 고객은 다시 124번으로 문의해서 일을 처리한다.
-
두번째 사례(forward)
- 고객이 고객센터로 상담원에게 123번으로 전화를 건다.
- 상담원은 해당 문의사항에 대해 잘 알지 못해서 옆의 다른 상담원에게 해당 문의사항에 답을 얻는다.
- 상담원은 고객에게 문의사항을 처리해준다.
-
첫번째 사례(redirect)
-
redirect의 경우 최초 요청을 받은 URL1에서 클라이언트에 redirect할 URL2를 리턴하고, 클라이언트에게 전혀 새로운 요청을 생성하여 URL2에 다시 요청을 보낸다. 따라서 처음 보냈던 최초의 요청정보는 더이상 유효하지 않게된다.
- web container는 redirect 명령이 들어오면 웹 브라우저에게 다른 페이지로 이동하라는 명령을 내린다.(첫번째 사례의 경우, 고객은 전화를 끊고 124번으로 다시 전화를 건다)
- 다른 web container에 있는 주소로 이동 가능(ex: 123 -> 124)
- 새로운 페이지에서는 request, response객체가 새롭게 생성된다.(123번에서 고객이 요청했던 문의사항은 사라지고 124번으로 다시 걸어서 요청한 문의사항을 다시 말해야한다.)
- web container는 redirect 명령이 들어오면 웹 브라우저에게 다른 페이지로 이동하라는 명령을 내린다.(첫번째 사례의 경우, 고객은 전화를 끊고 124번으로 다시 전화를 건다)
-
forwar방식은 다음 이동한 URL로 요청정보를 그대로 전달한다. 말 그대로 forward(건네주기)하는 것이다. 그렇기 때문에 사용자가 최초로 요청한 요청정보는 다음 URL에서도 유효하다.
- web container 차원(서버단)에서의 페이지 이동, 실제로 웹 브라우저는 다른 페이지로 이동했는지 알 수 없다.(두번째 사례의 경우, 고객은 상담원이 누구한테 물어봤는지 알 수 없다.)
- 동일한 web container에 있는 페이지로만 이동이 가능하다.
- 현재 실행중인 페이지와 forward에 의해 호출될 페이지는 request, response 객체를 공유한다.(고객이 요청한 문의사항은 고객이 전화를 끊을 때까지 유효하다.)
- web container 차원(서버단)에서의 페이지 이동, 실제로 웹 브라우저는 다른 페이지로 이동했는지 알 수 없다.(두번째 사례의 경우, 고객은 상담원이 누구한테 물어봤는지 알 수 없다.)
-
차이점
- 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 할때 Model처럼 파라미터를 추가해서 간편히 넘겨줄 수 있고, URL 인코딩 문제에서 자유롭다!
웹 브라우저에서의 redirect 특징
웹 브라우저는 3xx 응답의 결과에 Location 헤더가 있으면, Location 위치로 자동 이동한다.
- url : /event로 get을 요청
- 하지만 url이 /event에서 /new-event로 바뀌어서 서버가 다시 Location으로 응답
- 자동 리다이렉트로 url 변경, 클라이언트 단에서 스스로 자동 리다이렉트
- 다시 /new-event로 get 요청 -> 처음부터 다시 요청한다.
- 정상적으로 성공했기에 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가지-쿼리 파라미터, 메시지 바디)
-
정적 데이터 조회 -> 쿼리 파라미터 미사용
- 이미지, 정적 텍스트 문서
-
동적 데이터 조회 -> 쿼리 파라미터 사용
- 주로 검색, 게시판 목록에서 정렬 필터(검색어)
-
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)
-
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
- HTTP/1.1 201 Created
- 컬렉션(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<
“로그인 인증 방식”
참고: DB에 PW를 보통 암호화해서 넣어두는데, 로그인 인증 때 ID만 비교해서 값을 찾아오고 ID 있으면 같이 가져온 PW를 복호화해서 비교하는 순으로 하는게 효율적이고 빠르다는 것.
(1) Session(서버 메모리) 방식
AOP챕터의 -ArgumentResolver 예시 코드의 로그인 로직 참고
동작흐름: HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
interceptor 먼저 수행 후 -> argumentresolver 수행 순서
(2) JWT(토큰) -> Spring Security 사용 (코드포함)
Spring Security??
-
Spring
내에서Filter
를 기반으로 동작한다. 즉,Http Request
가Dispatcher Servlet
에 도착하기 이전,Spring Security
가 제공하는 기능을 활용하여, 인증 혹은 인가에 대한 사전 검증을 진행한다. - 참고: HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
-
주요기능 -> 참고 문서
-
UserDetails 인터페이스 -> 인증, 인가(권한부여) 기능 제공
- 실무 관례: UserDetails 상속받는 CustomUserDetails 클래스 만들어 사용
- getAuthorities() : 계정의 권한 목록을 리턴한다.
- getPassword() : 계정의 비밀번호를 리턴한다.
- getUsername() : 계정의 고유한 값을 리턴한다. (중복 없는 이메일, PK)
- isAccountNonExpired() : 계정의 만료 여부를 리턴한다.
- isAccountNonLocked() : 계정의 잠김 여부를 리턴한다.
- isEnabled() : 계정의 활성화 여부를 리턴한다.
- 기본 제공 메서드 말고, 커스텀하여 추가 메서드 예시? getEmail() 이런거!
-
UserDetailsService 인터페이스 -> 사용자 정보 조회 위한 서비스 단
- loadUserByUsername(String token) : 사용자의 토큰에서
claim
정보를 기반으로, 사용자를 조회하여, 조회된 정보를 기반으로UserDetails
인스턴스를 생성한다.- claim 정보란 JWT의 사용자 정보인 키-값 쌍 데이터!
- loadUserByUsername(String token) : 사용자의 토큰에서
-
Authentication 인터페이스 -> 사용자 정보와 권한 담음
- 동작 과정을 보면 알지만 UsernamePasswordAuthenticationToken, UserDetails를 다 가지게 된다.
-
AuthenticationProvider 인터페이스 ->
authenticate()
와supports()
메서드를 오버라이드- authenticate() : 실제 인증 로직 구현이 이루어지는 메서드이고,
UsernamePasswordAuthenticationToken
로 토큰을 만들며, 이 토큰 클래스는UserDetails
클래스를 기반으로Authentication
객체로 반환 한다. - supports() : 현재
Provider
클래스가 특정 타입의Authentication
객체를 지원하는 여부를 알려주는 메서드이다. 예로, 인증객체(=authentication)가 특정 인증 타입(=UsernamePasswordAuthenticationToken)을 지원하는지 판별-
예시 코드:
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
-
예시 코드:
- authenticate() : 실제 인증 로직 구현이 이루어지는 메서드이고,
-
AuthenticationManager ->
AuthenticationProvider
구현체를 관리- 여러 Provider 구현체를 AuthenticationManager 에 담아 두면 상황에 따라 필요한 Provider 구현체를 불러오는 것이 가능하다.
-
SecurityContext -> 인증 정보를 서버 내에서 보관 및
@AuthenticationPrincipal
을 사용하여 인증 정보를 가져올 수 있다- 동작 과정을 보면 알지만 Authentication를 포함하는 구조다.
-
GrandAuthority -> 현재 사용자가 가진 권한을 의미 (권한 확인에 사용)
- 예로 ROLE_USER, ROLE_ADMIN 등을 정의하거나 체크할 수 있다.
-
UserDetails 인터페이스 -> 인증, 인가(권한부여) 기능 제공
Spring Security 처리과정
- 사용자가 로그인 정보와 함께 인증 요청을 진행한다.
-
AuthenticationFilter
가 해당 요청에 대하여UsernamePasswordToken
객체를 생성한다. -
AuthenticationManager
에, 해당UsernamePassowordToken
객체를 전달한다. -
AuthetincationManager
는 전달된UsernamePassowordToken
객체의 인증을 지원하는AuthenticationProvider
를 찾아, 인증을 요구한다. -
AutheticationProvider
는authenticate()
메서드를 호출하여, 인증 로직이 수행되도록 한다. 인증 로직의 경우 서비스마다 차이는 있겠으나, 공통적인 수행 방식은 다음과 같다.-
UserDetailsService
에서 해당token
값의claims
정보를 기반으로 해당하는 사용자를 찾는다. - 인증 과정을 수행한 후, 성공적으로 인증이 되었다면 해당 사용자 객체 정보를 기반으로
UserDetails
객체를 생성하여 반환한다. - 인증이 완료된
UserDetails
객체를 기반으로, 사용자 정보 및 권한 정보가 담긴Authentication
객체를 생성한다. -
Authentication
객체를SecurityContext
에 저장한다. 향후Controller
에서 사용자 정보나 권한 정보가 필요한 경우,@AuthenticationPrincipal
등을 사용하여, 필요한 객체 정보를 불러올 수 있다.
-
Spring Security 장점
- 간편한 인증 및 인가: 기본적인 인증 및 인가 기능이 내장
- 자동 세션 관리: 간단한 코드 설정으로 로그인 시 자동으로 세션을 관리하고, 로그아웃 시 세션을 무효화
-
CSRF 보호: 기본적으로 CSRF 공격에 대한 보호 기능이 제공
- CSRF(크로스 사이트 요청 위조, Cross-Site Request Forgery) 공격은 공격자가 사용자의 인증된 세션을 이용해, 사용자가 의도하지 않은 요청을 전송하게 만드는 공격 방식
- 비밀번호 암호화: PasswordEncoder로 간단히 비밀번호 암호화
예전버전 -> 현재버전 바뀐점들 많다!! 공문 참고
-
예전 버전은
WebSecurityConfigurerAdapter
가Deprecated
되었으니SecurityFilterChain
를Bean
으로 등록해서 사용하는 방식을 써라. -
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줄!)
-
해당 필터 내부에는 이미 로그아웃 핸들러도 존재 -> 아래 다 자동!~!!
-
세션 무효화 핸들러
-
인증토큰, SecurityContext 삭제 핸들러
-
쿠키정보 삭제 핸들러
-
-
-
-
마지막으로 @Login 애노테이션으로 memberId (세션에 저장된) 를 가져오는것처럼
@AuthenticationPrincipal 로 바로 가져와 사용 가능!
-
-
API 기반의 애플리케이션에서 Spring Security로 로그인과 로그아웃을 처리할 때는
formLogin
이나logout
설정을 사용하지 않고, 주로 JWT나 OAuth2 같은 토큰 기반 인증 방식을 사용- 웹의 경우: 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="업로드 파일명"
- Content-Disposition 헤더에
- “/attach/{itemId}” - <a> 태그 “href” 활용해 “파일명” 을 눌러서 접근하게 한 URL 경로
-
댓글남기기