Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[step2] 리팩터링 #5

Open
wants to merge 6 commits into
base: jin-jaylin
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/main/java/nextstep/app/config/AuthConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import nextstep.security.access.matcher.MvcRequestMatcher;
import nextstep.security.authentication.*;
import nextstep.security.authorization.LoginAuthorizationFilter;
import nextstep.security.authorization.SessionAuthorizationFilter;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

진님 말씀처럼 요구사항에 용어 정리가 잘 안되어 있었어요 🥲

첫번째 요구사항은 인증이 완료된 사용자의 정보를 응답하는게 맞습니다.
미션 문서를 만들 때는 인증을 위해 인증 정보를 전달하는 방식 중 하나가 로그인이라고 생각했었어요. (엄밀하게 말해서는 Form 기반 로그인이겠네요 ㅎㅎ)

인증 처리는 요청으로 들어온 인증 정보에 있는 값이 유효한지(요청에서 주장하는 사용자가 맞는지)를 확인하는 과정이고, 인증이 완료되었다는 것은 인증 정보로 들어온 principal, credential에 해당하는 사용자가 있다는 뜻이죠. 우리가 만드는 프레임워크에서는 인증이 완료 되었을 때 인증 객체(Authentication)에 isAuthenticated()를 호출하면 true 값이 반환 되구요.

어제 강의에서 다뤘던 것처럼 Form 기반 로그인은 인증 요청과 리소스 접근(인가) 요청이 분리되어 있기 때문에 인증 객체(를 담고있는 SecurityContext)의 영속화가 필요하구요. (이게 말씀하신 session에 setAttribute)

정리해보자면 아래와 같습니다.

  • /members: 인증 완료된 'ADMIN' 사용자만 접근 가능
  • /memebers/me: 인증 완료된 모든 사용자가 접근 가능
  • 인증 방식은 Basic 인증, Form 기반 로그인을 지원
  • 인증 완료: Authentication.isAuthenticated() == true
    • Basic 인증: 인증 완료된 인증 객체를 인가 처리시 바로 사용
    • Form 기반 로그인: 인증 완료된 인증 객체를 저장해뒀다가, 다음 요청에서 인가 처리시 사용

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네네, 인증과 인가,,,, 과연 로그인은 그럼 어디에 속하는가가 조금 많이 헷갈렸는데요.
분명 인가에 대한 내용 같지만 과제 중간 제목에 로그인한 사용자에 대한 체크라는 부분이 있어서, session에 setAttribute한 부분까지 체크를 해야하는가? 하는 의문이 컸습니다. 그래서 실제로 MemberTest에보면 session set 까지 해주는 부분이 있고요^_ㅠ

말씀주신 내용 참고해서 다시 코드 개선해보겠습니다!
제가 요구사항을 완벽하게 숙지하지 못해서 더 헷갈린 것 같아요. 🙏

import nextstep.security.authorization.RoleAuthorizationFilter;
import nextstep.security.config.AuthorizeRequestMatcherRegistry;
import nextstep.security.config.DefaultSecurityFilterChain;
import nextstep.security.config.FilterChainProxy;
import nextstep.security.config.SecurityFilterChain;
Expand All @@ -29,6 +30,15 @@ public AuthConfig(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}

@Bean
public AuthorizeRequestMatcherRegistry requestMatcherRegistryMapping() {
AuthorizeRequestMatcherRegistry requestMatcherRegistry = new AuthorizeRequestMatcherRegistry();
requestMatcherRegistry
.matcher(new MvcRequestMatcher(HttpMethod.GET, "/members")).hasAuthority("ADMIN")
.matcher(new MvcRequestMatcher(HttpMethod.GET, "/members/me")).authenticated();
return requestMatcherRegistry;
}

@Bean
public DelegatingFilterProxy securityFilterChainProxy() {
return new DelegatingFilterProxy("filterChainProxy");
Expand All @@ -50,8 +60,8 @@ public SecurityFilterChain loginSecurityFilterChain() {
public SecurityFilterChain membersSecurityFilterChain() {
List<Filter> filters = new ArrayList<>();
filters.add(new BasicAuthenticationFilter(authenticationManager()));
filters.add(new LoginAuthorizationFilter(securityContextRepository()));
filters.add(new RoleAuthorizationFilter());
filters.add(new SessionAuthorizationFilter(securityContextRepository(), requestMatcherRegistryMapping()));
filters.add(new RoleAuthorizationFilter(requestMatcherRegistryMapping()));
return new DefaultSecurityFilterChain(new MvcRequestMatcher(HttpMethod.GET, "/members"), filters);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요청에 따라 인가 방법을 결정할 수 있게 되면서, securityFilterChain도 도 하나로 통일 할 수 있습니다.
이 부분 한 번 수정해보세요!

(+) 왜 securityFilterChain을 하나로 관리하는 요구사항이 있을지도 고민해보셨으면 좋겠어요!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네엥... SessionAuthorizationFilter이 위 계속 문의드린 로그인 관련된 기능이었는데 제외시키고 나머지도 하나로 통일 시켜보겠습네다. 👍

}

Expand All @@ -64,5 +74,4 @@ public SecurityContextRepository securityContextRepository() {
public AuthenticationManager authenticationManager() {
return new AuthenticationManager(new UsernamePasswordAuthenticationProvider(userDetailsService));
}

}
7 changes: 7 additions & 0 deletions src/main/java/nextstep/app/ui/MemberController.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,11 @@ public ResponseEntity<List<Member>> list() {
return ResponseEntity.ok(members);
}

@GetMapping("/members/me")
public ResponseEntity<Member> me() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍👍

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String email = authentication.getPrincipal().toString();
Member member = memberRepository.findByEmail(email).orElseThrow();
return ResponseEntity.ok(member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ public boolean matches(HttpServletRequest request) {
return false;
}

return request.getRequestURI().contains(pattern);
return request.getRequestURI().contains(pattern) || request.getRequestURI().startsWith(pattern);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package nextstep.security.authorization;

import nextstep.security.access.matcher.MvcRequestMatcher;
import nextstep.security.authentication.Authentication;
import nextstep.security.config.AuthorizeRequestMatcherRegistry;
import nextstep.security.context.SecurityContextHolder;
import nextstep.security.exception.AuthenticationException;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.GenericFilterBean;

Expand All @@ -17,22 +16,22 @@
import java.io.IOException;

public class RoleAuthorizationFilter extends GenericFilterBean {
private static final MvcRequestMatcher DEFAULT_REQUEST_MATCHER = new MvcRequestMatcher(HttpMethod.GET,
"/members");

private static final String ADMIN_ROLE = "ADMIN";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 값은 더이상 사용하지 않는 값이네요.


private final AuthorizeRequestMatcherRegistry requestMatcherRegistry;

public RoleAuthorizationFilter(AuthorizeRequestMatcherRegistry requestMatcherRegistry) {
this.requestMatcherRegistry = requestMatcherRegistry;
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
if (!DEFAULT_REQUEST_MATCHER.matches((HttpServletRequest) request)) {
chain.doFilter(request, response);
return;
}

String attribute = requestMatcherRegistry.getAttribute((HttpServletRequest) request);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean isAuthorized = requestMatcherRegistry.isAuthorized(attribute, authentication);

if (!authentication.getAuthorities().contains(ADMIN_ROLE)) {
if (!isAuthorized) {
Comment on lines +30 to +34
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이와같이 사용한다면 굳이 attribute를 registry에서 꺼내오지 않고 registry에 메시지를 보내도 괜찮지 않을까요?

boolean isAuthorized = requestMatcherRegistry.isAuthorized(authentication);

((HttpServletResponse) response).sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package nextstep.security.authorization;

import nextstep.security.access.matcher.MvcRequestMatcher;
import nextstep.security.config.AuthorizeRequestMatcherRegistry;
import nextstep.security.context.SecurityContext;
import nextstep.security.context.SecurityContextHolder;
import nextstep.security.context.SecurityContextRepository;
import nextstep.security.exception.AuthenticationException;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.GenericFilterBean;

Expand All @@ -17,33 +16,33 @@
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class LoginAuthorizationFilter extends GenericFilterBean {
private static final MvcRequestMatcher DEFAULT_REQUEST_MATCHER = new MvcRequestMatcher(HttpMethod.GET,
"/members");
public class SessionAuthorizationFilter extends GenericFilterBean {

private final SecurityContextRepository securityContextRepository;

public LoginAuthorizationFilter(SecurityContextRepository securityContextRepository) {
private final AuthorizeRequestMatcherRegistry requestMatcherRegistry;

public SessionAuthorizationFilter(SecurityContextRepository securityContextRepository, AuthorizeRequestMatcherRegistry requestMatcherRegistry) {
this.securityContextRepository = securityContextRepository;
this.requestMatcherRegistry = requestMatcherRegistry;
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
if (!DEFAULT_REQUEST_MATCHER.matches((HttpServletRequest) request)) {
chain.doFilter(request, response);
return;
}

String attribute = requestMatcherRegistry.getAttribute((HttpServletRequest) request);
SecurityContext loadedContext = securityContextRepository.loadContext((HttpServletRequest) request);
if (loadedContext == null) {
((HttpServletResponse) response).sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
return;
}
boolean isAuthorized = requestMatcherRegistry.isAuthorized(attribute, loadedContext.getAuthentication());

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(loadedContext.getAuthentication());
SecurityContextHolder.setContext(context);
if (isAuthorized) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(loadedContext.getAuthentication());
SecurityContextHolder.setContext(context);
}
} catch (AuthenticationException e) {
((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package nextstep.security.config;

import nextstep.security.access.matcher.RequestMatcher;
import nextstep.security.authentication.Authentication;

import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class AuthorizeRequestMatcherRegistry {
private final Map<RequestMatcher, String> mappings = new HashMap<>();

public AuthorizedUrl matcher(RequestMatcher requestMatcher) {
return new AuthorizedUrl(requestMatcher);
}

AuthorizeRequestMatcherRegistry addMapping(RequestMatcher requestMatcher, String attributes) {
mappings.put(requestMatcher, attributes);
return this;
}

public String getAttribute(HttpServletRequest request) {
for (Map.Entry<RequestMatcher, String> entry : mappings.entrySet()) {
if (entry.getKey().matches(request)) {
return entry.getValue();
}
}

return null;
}

public class AuthorizedUrl {
public static final String PERMIT_ALL = "permitAll";
public static final String DENY_ALL = "denyAll";
public static final String AUTHENTICATED = "authenticated";
public static final String AUTHORITY = "hasAuthority";

private final RequestMatcher requestMatcher;

public AuthorizedUrl(RequestMatcher requestMatcher) {
this.requestMatcher = requestMatcher;
}

public AuthorizeRequestMatcherRegistry permitAll() {
return access(PERMIT_ALL);
}

public AuthorizeRequestMatcherRegistry denyAll() {
return access(DENY_ALL);
}

public AuthorizeRequestMatcherRegistry hasAuthority(String authority) {
return access(AUTHORITY + "(" + authority + ")");
}

public AuthorizeRequestMatcherRegistry authenticated() {
return access(AUTHENTICATED);
}

private AuthorizeRequestMatcherRegistry access(String attribute) {
return addMapping(requestMatcher, attribute);
}
}

public Boolean isAuthorized(String attribute, Authentication authentication) {
if (attribute.contains(AuthorizedUrl.AUTHORITY)) {
Pattern pattern = Pattern.compile("\\((.*?)\\)");
Matcher matcher = pattern.matcher(attribute);
if(matcher.find()) {
String role = matcher.group(1).trim();
return authentication.getAuthorities().contains(role);
}
} else if (attribute.contains(AuthorizedUrl.AUTHENTICATED)) {
return authentication.isAuthenticated();
}
Comment on lines +68 to +77
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인가 방법이 늘어날수록 if - else if - else if ... 구문이 추가되겠네요.
지금은 mappings가 String으로 되어있는데, 이 부분을 인가에 필요한 값을 쉽게 가져올 수 있는 구조로 변경해봐도 좋을 것 같아요.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메소드 하나 내에서 난리친것을 인정합니다.. 반성합니다..
예쁘게 추상화 해보겠습네다 🙏


return false;
}

}
74 changes: 62 additions & 12 deletions src/test/java/nextstep/app/MemberAcceptanceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,86 @@ void get_members_after_form_login() {
params.put("username", TEST_MEMBER.getEmail());
params.put("password", TEST_MEMBER.getPassword());

ExtractableResponse<Response> loginResponse = RestAssured.given().log().all()
.formParams(params)
.contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.when()
.post("/login")
.then().log().all()
.extract();
ExtractableResponse<Response> loginResponse = requestLogin(params);

ExtractableResponse<Response> memberResponse = requestMembers(loginResponse);

assertThat(memberResponse.statusCode()).isEqualTo(HttpStatus.OK.value());
List<Member> members = memberResponse.jsonPath().getList(".", Member.class);
assertThat(members.size()).isEqualTo(2);
}

@Test
void get_members_before_form_login() {
ExtractableResponse<Response> memberResponse = RestAssured.given().log().all()
.cookies(loginResponse.cookies())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.get("/members")
.then().log().all()
.extract();

assertThat(memberResponse.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value());
}

@Test
void get_members_me_after_form_login() {
Map<String, String> params = new HashMap<>();
params.put("username", TEST_MEMBER.getEmail());
params.put("password", TEST_MEMBER.getPassword());

ExtractableResponse<Response> loginResponse = requestLogin(params);

ExtractableResponse<Response> memberResponse = requestMembersMe(loginResponse);

assertThat(memberResponse.statusCode()).isEqualTo(HttpStatus.OK.value());
List<Member> members = memberResponse.jsonPath().getList(".", Member.class);
assertThat(members.size()).isEqualTo(2);
assertThat(memberResponse.jsonPath().getString("email")).isEqualTo(TEST_MEMBER.getEmail());
}

@Test
void get_members_before_form_login() {
void get_members_me_before_form_login() {
ExtractableResponse<Response> memberResponse = requestMembersMe(null);

assertThat(memberResponse.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value());
}

private static ExtractableResponse<Response> requestLogin(Map<String, String> params) {
ExtractableResponse<Response> loginResponse = RestAssured.given().log().all()
.formParams(params)
.contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.when()
.post("/login")
.then().log().all()
.extract();
return loginResponse;
}

private static ExtractableResponse<Response> requestMembers(ExtractableResponse<Response> loginResponse) {
ExtractableResponse<Response> memberResponse = RestAssured.given().log().all()
.cookies(loginResponse.cookies())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.get("/members")
.then().log().all()
.extract();
return memberResponse;
}

assertThat(memberResponse.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value());
private static ExtractableResponse<Response> requestMembersMe(ExtractableResponse<Response> loginResponse) {
if (loginResponse == null) {
return RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.get("/members/me")
.then().log().all()
.extract();
} else {
return RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.cookies(loginResponse.cookies())
.when()
.get("/members/me")
.then().log().all()
.extract();
}
}
}
Loading