diff --git a/README.md b/README.md index 1e7ba65..336d92d 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ # spring-security-authentication + +1. LoginController 를 통한 인증 관리 + - /login URL 에 `MemberAuthorizationInterceptor` 를 적용 + - `MemberAuthorizationInterceptor` 는 세션에 이미 저장된 인증값이 있는 경우 바이패스 됨 + - 세션 인증값이 없는 경우 주어진 request parameter 로 repository 에서 `Member` 를 조회 + - 조회 결과가 있는 경우 세션 업데이트, 인증 처리 함 + + +2. MemberController 를 통한 인가 관리 +- /member URL 에 `BasicAuthenticationInterceptor` 를 적용 +- `BasicAuthenticationInterceptor` 는 토큰을 받아 해당 토큰이 유효한지 여부를 판단 +- 이 때 토큰을 decode 하기 위해 `BasicAuthenticationService` 에서 Base64 기준 토큰 분해 및 `Member` 객체에 담아서 리턴 +- `BasicAuthenticationInterceptor` 는 토큰을 분해해서 얻은 Member 의 email 을 조회하여 인가 여부를 결정 +- 토큰값이 유효하지 않은 경우 `InvalidTokenExcpetion` 발생 +- 그 외 Interceptor 에서 무효 처리 (단순 false 리턴) + diff --git a/build.gradle b/build.gradle index 9976616..085c66b 100644 --- a/build.gradle +++ b/build.gradle @@ -12,8 +12,18 @@ repositories { mavenCentral() } +ext { + lombokVersion = '1.18.30' +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.apache.commons:commons-lang3' + + compileOnly group: 'org.projectlombok', name: 'lombok', version : lombokVersion + annotationProcessor group: 'org.projectlombok', name: 'lombok', version : lombokVersion + testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version : lombokVersion + testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/src/main/java/nextstep/app/configuration/SecurityConfiguration.java b/src/main/java/nextstep/app/configuration/SecurityConfiguration.java new file mode 100644 index 0000000..c1201b4 --- /dev/null +++ b/src/main/java/nextstep/app/configuration/SecurityConfiguration.java @@ -0,0 +1,69 @@ +package nextstep.app.configuration; + +import java.util.List; +import java.util.Optional; + +import lombok.RequiredArgsConstructor; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; +import nextstep.app.ui.AuthenticationException; +import nextstep.security.configuration.filter.BasicAuthenticationFilter; +import nextstep.security.configuration.filter.FormLoginFilter; +import nextstep.security.configuration.filter.SecurityContextHolderFilter; +import nextstep.security.model.UserDetails; +import nextstep.security.service.UserDetailsService; +import nextstep.security.service.filter.DefaultSecurityFilterChain; +import nextstep.security.service.filter.DelegateFilterProxy; +import nextstep.security.service.filter.FilterChainProxy; +import nextstep.security.service.filter.SecurityFilterChain; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfiguration { + + private final MemberRepository memberRepository; + + @Bean + public DelegateFilterProxy delegateFilterProxy() { + return new DelegateFilterProxy(filterChainProxy(List.of(securityFilterChain()))); + } + + @Bean + public FilterChainProxy filterChainProxy(List securityFilterChains) { + return new FilterChainProxy(securityFilterChains); + } + + @Bean + public SecurityFilterChain securityFilterChain() { + return new DefaultSecurityFilterChain(List.of( + new SecurityContextHolderFilter(), + new FormLoginFilter(userDetailsService()), + new BasicAuthenticationFilter(userDetailsService()) + )); + } + + @Bean + public UserDetailsService userDetailsService() { + return new UserDetailsService() { + @Override + public Optional loadUserByUsername(String username) { + Member member = memberRepository.findByEmail(username).orElseThrow(AuthenticationException::new); + return Optional.ofNullable( + UserDetails.builder().userName(member.getEmail()).password(member.getPassword()).build()); + } + + @Override + public Optional loadUserByUsernameAndEmail(String username, String password) { + Member member = + memberRepository.findByEmailAndPassword(username, password) + .orElseThrow(AuthenticationException::new); + return Optional.ofNullable( + UserDetails.builder().userName(member.getEmail()).password(member.getPassword()).build()); + } + }; + } + + +} diff --git a/src/main/java/nextstep/app/domain/Member.java b/src/main/java/nextstep/app/domain/Member.java index 6cafa9c..eec51bc 100644 --- a/src/main/java/nextstep/app/domain/Member.java +++ b/src/main/java/nextstep/app/domain/Member.java @@ -1,6 +1,13 @@ package nextstep.app.domain; -public class Member { +import java.io.Serializable; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class Member implements Serializable { private final String email; private final String password; private final String name; @@ -13,19 +20,4 @@ public Member(String email, String password, String name, String imageUrl) { this.imageUrl = imageUrl; } - public String getEmail() { - return email; - } - - public String getPassword() { - return password; - } - - public String getName() { - return name; - } - - public String getImageUrl() { - return imageUrl; - } } diff --git a/src/main/java/nextstep/app/domain/MemberRepository.java b/src/main/java/nextstep/app/domain/MemberRepository.java index 2eb5cdb..b5f5692 100644 --- a/src/main/java/nextstep/app/domain/MemberRepository.java +++ b/src/main/java/nextstep/app/domain/MemberRepository.java @@ -6,6 +6,8 @@ public interface MemberRepository { Optional findByEmail(String email); + Optional findByEmailAndPassword(String email, String password); + List findAll(); void save(Member member); diff --git a/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java b/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java index 5a6062c..ae54922 100644 --- a/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java +++ b/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java @@ -1,15 +1,15 @@ package nextstep.app.infrastructure; -import nextstep.app.domain.Member; -import nextstep.app.domain.MemberRepository; -import org.springframework.stereotype.Repository; - import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; +import org.springframework.stereotype.Repository; + @Repository public class InmemoryMemberRepository implements MemberRepository { public static final Member TEST_MEMBER_1 = new Member("a@a.com", "password", "a", ""); @@ -26,6 +26,14 @@ public Optional findByEmail(String email) { return Optional.ofNullable(members.get(email)); } + @Override + public Optional findByEmailAndPassword(String email, String password) { + return members.values() + .stream() + .filter(member -> member.getEmail().equals(email) && member.getPassword().equals(password)) + .findFirst(); + } + @Override public List findAll() { return members.values().stream().collect(Collectors.toUnmodifiableList()); diff --git a/src/main/java/nextstep/app/ui/LoginController.java b/src/main/java/nextstep/app/ui/LoginController.java index 0ea94f1..dc08b43 100644 --- a/src/main/java/nextstep/app/ui/LoginController.java +++ b/src/main/java/nextstep/app/ui/LoginController.java @@ -1,27 +1,16 @@ package nextstep.app.ui; -import nextstep.app.domain.MemberRepository; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - @RestController public class LoginController { - public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; - - private final MemberRepository memberRepository; - - public LoginController(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } @PostMapping("/login") - public ResponseEntity login(HttpServletRequest request, HttpSession session) { + public ResponseEntity login() { return ResponseEntity.ok().build(); } @@ -29,4 +18,6 @@ public ResponseEntity login(HttpServletRequest request, HttpSession sessio public ResponseEntity handleAuthenticationException() { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + } + diff --git a/src/main/java/nextstep/app/ui/MemberController.java b/src/main/java/nextstep/app/ui/MemberController.java index c8cc74d..7a6833d 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -1,26 +1,31 @@ package nextstep.app.ui; +import java.util.List; + +import lombok.RequiredArgsConstructor; import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController +@RequiredArgsConstructor public class MemberController { private final MemberRepository memberRepository; - public MemberController(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } - @GetMapping("/members") public ResponseEntity> list() { List members = memberRepository.findAll(); return ResponseEntity.ok(members); } + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException() { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } diff --git a/src/main/java/nextstep/security/configuration/filter/BasicAuthenticationFilter.java b/src/main/java/nextstep/security/configuration/filter/BasicAuthenticationFilter.java new file mode 100644 index 0000000..eb5bbbc --- /dev/null +++ b/src/main/java/nextstep/security/configuration/filter/BasicAuthenticationFilter.java @@ -0,0 +1,87 @@ +package nextstep.security.configuration.filter; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import nextstep.app.ui.AuthenticationException; +import nextstep.security.model.UserDetails; +import nextstep.security.model.authentication.Authentication; +import nextstep.security.model.authentication.UsernamePasswordAuthenticationToken; +import nextstep.security.model.context.SecurityContext; +import nextstep.security.service.BasicAuthenticationService; +import nextstep.security.service.UserDetailsService; +import nextstep.security.service.authentication.AuthenticationManager; +import nextstep.security.service.authentication.DaoAuthenticationProvider; +import nextstep.security.service.authentication.ProviderManager; +import nextstep.security.service.context.SecurityContextHolder; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.web.filter.GenericFilterBean; + +import static nextstep.security.utils.Constants.BASIC_TOKEN_PREFIX; +import static nextstep.security.utils.Constants.GET_MEMBERS_ENDPOINT_ADDRESS; + +public class BasicAuthenticationFilter extends GenericFilterBean { + + private final BasicAuthenticationService basicAuthenticationService = new BasicAuthenticationService(); + private final AuthenticationManager authenticationManager; + + public BasicAuthenticationFilter(UserDetailsService userDetailsService) { + authenticationManager = new ProviderManager(List.of(new DaoAuthenticationProvider(userDetailsService))); + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; + + if (!GET_MEMBERS_ENDPOINT_ADDRESS.equals(httpRequest.getRequestURI())) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } else if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + + try { + Authentication authenticationRequest = convert(httpRequest); + Authentication authenticated = authenticationManager.authenticate(authenticationRequest); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authenticated); + SecurityContextHolder.setContext(context); + + filterChain.doFilter(servletRequest, servletResponse); + } catch (Exception e) { + ((HttpServletResponse) servletResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + } + + private Authentication convert(HttpServletRequest httpRequest) { + String authorizationHeader = httpRequest.getHeader(HttpHeaders.AUTHORIZATION); + + if (StringUtils.isEmpty(authorizationHeader) || !authorizationHeader.startsWith(BASIC_TOKEN_PREFIX)) { + throw new AuthenticationException(); + } + + UserDetails decodedUserDetails = basicAuthenticationService.mapTokenToUserDetails(authorizationHeader); + + if (Objects.isNull(decodedUserDetails)) { + throw new AuthenticationException(); + } + + return UsernamePasswordAuthenticationToken.unauthenticated( + decodedUserDetails.getUserName(), + decodedUserDetails.getPassword()); + } +} diff --git a/src/main/java/nextstep/security/configuration/filter/FormLoginFilter.java b/src/main/java/nextstep/security/configuration/filter/FormLoginFilter.java new file mode 100644 index 0000000..8c70e1a --- /dev/null +++ b/src/main/java/nextstep/security/configuration/filter/FormLoginFilter.java @@ -0,0 +1,81 @@ +package nextstep.security.configuration.filter; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import nextstep.security.model.authentication.Authentication; +import nextstep.security.model.authentication.UsernamePasswordAuthenticationToken; +import nextstep.security.model.context.SecurityContext; +import nextstep.security.repository.HttpSessionSecurityContextRepository; +import nextstep.security.service.UserDetailsService; +import nextstep.security.service.authentication.AuthenticationManager; +import nextstep.security.service.authentication.DaoAuthenticationProvider; +import nextstep.security.service.authentication.ProviderManager; +import nextstep.security.service.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.GenericFilterBean; + +import static nextstep.security.utils.Constants.LOGIN_ENDPOINT_ADDRESS; +import static nextstep.security.utils.Constants.PASSWORD_ATTRIBUTE_NAME; +import static nextstep.security.utils.Constants.USERNAME_ATTRIBUTE_NAME; + +@Component +public class FormLoginFilter extends GenericFilterBean { + + private final AuthenticationManager authenticationManager; + private final HttpSessionSecurityContextRepository httpSessionSecurityContextRepository = new HttpSessionSecurityContextRepository(); + + public FormLoginFilter(UserDetailsService userDetailsService) { + authenticationManager = new ProviderManager( + List.of(new DaoAuthenticationProvider(userDetailsService)) + ); + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; + + if (!LOGIN_ENDPOINT_ADDRESS.equals(httpRequest.getRequestURI())) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + + try { + Authentication authenticationRequest = convert(httpRequest); + + if (Objects.isNull(authenticationRequest)) { + ((HttpServletResponse) servletResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + Authentication authenticated = authenticationManager.authenticate(authenticationRequest); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authenticated); + SecurityContextHolder.setContext(context); + httpSessionSecurityContextRepository.saveContext(context, httpRequest, + (HttpServletResponse) servletResponse); + } catch (Exception e) { + ((HttpServletResponse) servletResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + } + + private Authentication convert(HttpServletRequest httpRequest) { + try { + return UsernamePasswordAuthenticationToken.unauthenticated( + httpRequest.getParameter(USERNAME_ATTRIBUTE_NAME), + httpRequest.getParameter(PASSWORD_ATTRIBUTE_NAME)); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/nextstep/security/configuration/filter/SecurityContextHolderFilter.java b/src/main/java/nextstep/security/configuration/filter/SecurityContextHolderFilter.java new file mode 100644 index 0000000..3230338 --- /dev/null +++ b/src/main/java/nextstep/security/configuration/filter/SecurityContextHolderFilter.java @@ -0,0 +1,30 @@ +package nextstep.security.configuration.filter; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +import nextstep.security.model.context.SecurityContext; +import nextstep.security.repository.HttpSessionSecurityContextRepository; +import nextstep.security.service.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +public class SecurityContextHolderFilter extends GenericFilterBean { + + private final HttpSessionSecurityContextRepository httpSessionSecurityContextRepository = new HttpSessionSecurityContextRepository(); + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + SecurityContext context = httpSessionSecurityContextRepository.loadContext( + (HttpServletRequest) servletRequest); + + SecurityContextHolder.setContext(context); + filterChain.doFilter(servletRequest, servletResponse); + SecurityContextHolder.clearContext(); + } +} diff --git a/src/main/java/nextstep/security/model/UserDetails.java b/src/main/java/nextstep/security/model/UserDetails.java new file mode 100644 index 0000000..0652f85 --- /dev/null +++ b/src/main/java/nextstep/security/model/UserDetails.java @@ -0,0 +1,13 @@ +package nextstep.security.model; + +import java.io.Serializable; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class UserDetails implements Serializable { + String userName; + String password; +} diff --git a/src/main/java/nextstep/security/model/authentication/Authentication.java b/src/main/java/nextstep/security/model/authentication/Authentication.java new file mode 100644 index 0000000..efcf9ad --- /dev/null +++ b/src/main/java/nextstep/security/model/authentication/Authentication.java @@ -0,0 +1,13 @@ +package nextstep.security.model.authentication; + +import java.io.Serializable; + +public interface Authentication extends Serializable { + + Object getCredentials(); + + Object getPrincipal(); + + boolean isAuthenticated(); + +} diff --git a/src/main/java/nextstep/security/model/authentication/UsernamePasswordAuthenticationToken.java b/src/main/java/nextstep/security/model/authentication/UsernamePasswordAuthenticationToken.java new file mode 100644 index 0000000..ad149ad --- /dev/null +++ b/src/main/java/nextstep/security/model/authentication/UsernamePasswordAuthenticationToken.java @@ -0,0 +1,34 @@ +package nextstep.security.model.authentication; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class UsernamePasswordAuthenticationToken implements Authentication { + + private final Object credentials; + private final Object principal; + private final boolean isAuthenticated; + + public static UsernamePasswordAuthenticationToken unauthenticated(String principal, String credentials) { + return new UsernamePasswordAuthenticationToken(credentials, principal, false); + } + + public static UsernamePasswordAuthenticationToken authenticated(String principal, String credentials) { + return new UsernamePasswordAuthenticationToken(credentials, principal, true); + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public boolean isAuthenticated() { + return isAuthenticated; + } +} diff --git a/src/main/java/nextstep/security/model/context/SecurityContext.java b/src/main/java/nextstep/security/model/context/SecurityContext.java new file mode 100644 index 0000000..4b0e330 --- /dev/null +++ b/src/main/java/nextstep/security/model/context/SecurityContext.java @@ -0,0 +1,21 @@ +package nextstep.security.model.context; + +import java.io.Serializable; + +import lombok.Getter; +import lombok.Setter; +import nextstep.security.model.authentication.Authentication; + +@Getter +@Setter +public class SecurityContext implements Serializable { + + private Authentication authentication; + + public SecurityContext() {} + + public SecurityContext(Authentication authentication) { + this.authentication = authentication; + } + +} diff --git a/src/main/java/nextstep/security/repository/HttpSessionSecurityContextRepository.java b/src/main/java/nextstep/security/repository/HttpSessionSecurityContextRepository.java new file mode 100644 index 0000000..94a52e7 --- /dev/null +++ b/src/main/java/nextstep/security/repository/HttpSessionSecurityContextRepository.java @@ -0,0 +1,31 @@ +package nextstep.security.repository; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import nextstep.security.model.context.SecurityContext; +import org.springframework.stereotype.Repository; + +import static nextstep.security.utils.Constants.SPRING_SECURITY_CONTEXT_KEY; + +/** +This class is not actually running upon JPA, it's kind of mock server + That's why this class contains logics itself. + */ +@Repository +public class HttpSessionSecurityContextRepository { + + public SecurityContext loadContext(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + return (SecurityContext) session.getAttribute(SPRING_SECURITY_CONTEXT_KEY); + } + public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) { + HttpSession session = request.getSession(); + session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); + } + +} diff --git a/src/main/java/nextstep/security/service/BasicAuthenticationService.java b/src/main/java/nextstep/security/service/BasicAuthenticationService.java new file mode 100644 index 0000000..a408a57 --- /dev/null +++ b/src/main/java/nextstep/security/service/BasicAuthenticationService.java @@ -0,0 +1,32 @@ +package nextstep.security.service; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Objects; + +import nextstep.security.model.UserDetails; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import static nextstep.security.utils.Constants.BASIC_TOKEN_PREFIX; + +@Service +public class BasicAuthenticationService { + + public UserDetails mapTokenToUserDetails(String token) { + if (Objects.isNull(token)) { + return null; + } + + String[] decodedToken = + new String(Base64.getDecoder().decode(token.replace(BASIC_TOKEN_PREFIX, "")), StandardCharsets.UTF_8) + .split(":"); + + if (decodedToken.length < 2 || StringUtils.isBlank(decodedToken[0]) || StringUtils.isBlank(decodedToken[1])) { + return null; + } + + return UserDetails.builder().userName(decodedToken[0]).password(decodedToken[1]).build(); + } + +} diff --git a/src/main/java/nextstep/security/service/UserDetailsService.java b/src/main/java/nextstep/security/service/UserDetailsService.java new file mode 100644 index 0000000..1164e7f --- /dev/null +++ b/src/main/java/nextstep/security/service/UserDetailsService.java @@ -0,0 +1,15 @@ +package nextstep.security.service; + +import java.util.Optional; + +import nextstep.security.model.UserDetails; +import org.springframework.stereotype.Service; + +@Service +public interface UserDetailsService { + + + Optional loadUserByUsername(String username); + Optional loadUserByUsernameAndEmail(String username, String email); + +} diff --git a/src/main/java/nextstep/security/service/authentication/AuthenticationManager.java b/src/main/java/nextstep/security/service/authentication/AuthenticationManager.java new file mode 100644 index 0000000..ef25574 --- /dev/null +++ b/src/main/java/nextstep/security/service/authentication/AuthenticationManager.java @@ -0,0 +1,9 @@ +package nextstep.security.service.authentication; + +import nextstep.security.model.authentication.Authentication; + +public interface AuthenticationManager { + + Authentication authenticate(Authentication authentication); + +} diff --git a/src/main/java/nextstep/security/service/authentication/AuthenticationProvider.java b/src/main/java/nextstep/security/service/authentication/AuthenticationProvider.java new file mode 100644 index 0000000..7e45305 --- /dev/null +++ b/src/main/java/nextstep/security/service/authentication/AuthenticationProvider.java @@ -0,0 +1,12 @@ +package nextstep.security.service.authentication; + +import nextstep.app.ui.AuthenticationException; +import nextstep.security.model.authentication.Authentication; + +public interface AuthenticationProvider { + + Authentication authenticate(Authentication authentication) throws AuthenticationException; + + boolean supports(Class authentication); + +} diff --git a/src/main/java/nextstep/security/service/authentication/DaoAuthenticationProvider.java b/src/main/java/nextstep/security/service/authentication/DaoAuthenticationProvider.java new file mode 100644 index 0000000..5371fa3 --- /dev/null +++ b/src/main/java/nextstep/security/service/authentication/DaoAuthenticationProvider.java @@ -0,0 +1,30 @@ +package nextstep.security.service.authentication; + +import lombok.RequiredArgsConstructor; +import nextstep.app.ui.AuthenticationException; +import nextstep.security.model.UserDetails; +import nextstep.security.model.authentication.Authentication; +import nextstep.security.model.authentication.UsernamePasswordAuthenticationToken; +import nextstep.security.service.UserDetailsService; + +@RequiredArgsConstructor +public class DaoAuthenticationProvider implements AuthenticationProvider{ + + private final UserDetailsService userDetailsService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + UserDetails userDetails = + userDetailsService.loadUserByUsernameAndEmail( + authentication.getPrincipal().toString(), + authentication.getCredentials().toString()) + .orElseThrow(AuthenticationException::new); + + return UsernamePasswordAuthenticationToken.authenticated(userDetails.getUserName(), userDetails.getPassword()); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/nextstep/security/service/authentication/ProviderManager.java b/src/main/java/nextstep/security/service/authentication/ProviderManager.java new file mode 100644 index 0000000..fc9fb7f --- /dev/null +++ b/src/main/java/nextstep/security/service/authentication/ProviderManager.java @@ -0,0 +1,22 @@ +package nextstep.security.service.authentication; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import nextstep.security.model.authentication.Authentication; + +@RequiredArgsConstructor +public class ProviderManager implements AuthenticationManager{ + + private final List authenticationProviders; + + @Override + public Authentication authenticate(Authentication authentication) { + for (AuthenticationProvider provider : authenticationProviders) { + if (provider.supports(authentication.getClass())) { + return provider.authenticate(authentication); + } + } + return null; + } +} diff --git a/src/main/java/nextstep/security/service/context/SecurityContextHolder.java b/src/main/java/nextstep/security/service/context/SecurityContextHolder.java new file mode 100644 index 0000000..20ab60f --- /dev/null +++ b/src/main/java/nextstep/security/service/context/SecurityContextHolder.java @@ -0,0 +1,39 @@ +package nextstep.security.service.context; + +import java.util.Objects; + +import nextstep.security.model.context.SecurityContext; + +public class SecurityContextHolder { + + private static final ThreadLocal contextHolder; + + static { + contextHolder = new ThreadLocal<>(); + } + + public static void clearContext() { + contextHolder.remove(); + } + + public static void setContext(SecurityContext context) { + if (Objects.nonNull(context)) { + contextHolder.set(context); + } + } + + public static SecurityContext getContext() { + if (Objects.nonNull(contextHolder.get())) { + return contextHolder.get(); + } else { + SecurityContext emptyContext = createEmptyContext(); + contextHolder.set(emptyContext); + return emptyContext; + } + } + + public static SecurityContext createEmptyContext() { + return new SecurityContext(); + } + +} diff --git a/src/main/java/nextstep/security/service/filter/DefaultSecurityFilterChain.java b/src/main/java/nextstep/security/service/filter/DefaultSecurityFilterChain.java new file mode 100644 index 0000000..23b8be9 --- /dev/null +++ b/src/main/java/nextstep/security/service/filter/DefaultSecurityFilterChain.java @@ -0,0 +1,24 @@ +package nextstep.security.service.filter; + +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DefaultSecurityFilterChain implements SecurityFilterChain{ + + private final List filters; + + @Override + public boolean matches(HttpServletRequest request) { + return true; + } + + @Override + public List getFilters() { + return filters; + } +} diff --git a/src/main/java/nextstep/security/service/filter/DelegateFilterProxy.java b/src/main/java/nextstep/security/service/filter/DelegateFilterProxy.java new file mode 100644 index 0000000..7cca265 --- /dev/null +++ b/src/main/java/nextstep/security/service/filter/DelegateFilterProxy.java @@ -0,0 +1,24 @@ +package nextstep.security.service.filter; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.GenericFilterBean; + +@RequiredArgsConstructor +public class DelegateFilterProxy extends GenericFilterBean { + + private final Filter delegateFilter; + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + delegateFilter.doFilter(servletRequest, servletResponse, filterChain); + } +} diff --git a/src/main/java/nextstep/security/service/filter/FilterChainProxy.java b/src/main/java/nextstep/security/service/filter/FilterChainProxy.java new file mode 100644 index 0000000..307d888 --- /dev/null +++ b/src/main/java/nextstep/security/service/filter/FilterChainProxy.java @@ -0,0 +1,70 @@ +package nextstep.security.service.filter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.GenericFilterBean; + +@RequiredArgsConstructor +public class FilterChainProxy extends GenericFilterBean { + + private final List securityFilterChains; + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + List filters = getFilters((HttpServletRequest) servletRequest); + + VirtualFilterChain virtualFilterChain = new VirtualFilterChain(filterChain, filters); + virtualFilterChain.doFilter(servletRequest, servletResponse); + } + + private List getFilters(HttpServletRequest httpServletRequest) { + for (SecurityFilterChain securityFilterChain : securityFilterChains) { + if (securityFilterChain.matches(httpServletRequest)) { + return securityFilterChain.getFilters(); + } + } + return Collections.emptyList(); + } + + private static final class VirtualFilterChain implements FilterChain { + + private final FilterChain originalChain; + + private final List inputFilters; + + private final int inputFilterSize; + + private int currentPosition = 0; + + private VirtualFilterChain(FilterChain originalChain, List inputFilters) { + this.originalChain = originalChain; + this.inputFilters = inputFilters; + inputFilterSize = inputFilters.size(); + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse) + throws IOException, ServletException { + + if (currentPosition == inputFilterSize) { + originalChain.doFilter(servletRequest, servletResponse); + return; + } + + currentPosition++; + Filter nextFilter = inputFilters.get(currentPosition - 1); + nextFilter.doFilter(servletRequest, servletResponse, this); + } + } +} diff --git a/src/main/java/nextstep/security/service/filter/SecurityFilterChain.java b/src/main/java/nextstep/security/service/filter/SecurityFilterChain.java new file mode 100644 index 0000000..e4f93ec --- /dev/null +++ b/src/main/java/nextstep/security/service/filter/SecurityFilterChain.java @@ -0,0 +1,14 @@ +package nextstep.security.service.filter; + +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.http.HttpServletRequest; + +public interface SecurityFilterChain { + + boolean matches(HttpServletRequest request); + + List getFilters(); + +} diff --git a/src/main/java/nextstep/security/utils/Constants.java b/src/main/java/nextstep/security/utils/Constants.java new file mode 100644 index 0000000..342abf7 --- /dev/null +++ b/src/main/java/nextstep/security/utils/Constants.java @@ -0,0 +1,10 @@ +package nextstep.security.utils; + +public class Constants { + public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; + public static final String BASIC_TOKEN_PREFIX = "Basic "; + public static final String USERNAME_ATTRIBUTE_NAME = "username"; + public static final String PASSWORD_ATTRIBUTE_NAME = "password"; + public static final String LOGIN_ENDPOINT_ADDRESS = "/login"; + public static final String GET_MEMBERS_ENDPOINT_ADDRESS = "/members"; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c80abf6 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,10 @@ +spring: + application: + name: spring-security-authentication + +logging: + level: + root: INFO + +server: + port: 8080 \ No newline at end of file diff --git a/src/test/java/nextstep/app/BasicAuthenticationServiceTest.java b/src/test/java/nextstep/app/BasicAuthenticationServiceTest.java new file mode 100644 index 0000000..de173a3 --- /dev/null +++ b/src/test/java/nextstep/app/BasicAuthenticationServiceTest.java @@ -0,0 +1,60 @@ +package nextstep.app; + +import java.util.Base64; +import java.util.stream.Stream; + +import nextstep.security.model.UserDetails; +import nextstep.security.service.BasicAuthenticationService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class BasicAuthenticationServiceTest { + + @InjectMocks + BasicAuthenticationService underTest; + + static Stream tokenProvider() { + return Stream.of( + Arguments.of("Basic invalid"), + null, + Arguments.of(""), + Arguments.of("Basic ") + ); + } + + @ParameterizedTest + @MethodSource("tokenProvider") + void mapTokenToUserDetails_shouldReturnNullWhenTokenIsCorrupted(String token) { + // arrange + // act + var result = underTest.mapTokenToUserDetails(token); + + // assert + assertThat(result).isNull(); + } + + @Test + void mapTokenToUserDetails_shouldReturnUserDetailsWithMatchedResult() { + // arrange + var email = "test_email"; + var password = "test_password"; + var token = "Basic " + Base64.getEncoder().encodeToString((email+":"+password).getBytes()); + + // act + var result = underTest.mapTokenToUserDetails(token); + + // assert + assertThat(result).isNotNull().isInstanceOf(UserDetails.class); + assertThat(result.getUserName()).isEqualTo(email); + assertThat(result.getPassword()).isEqualTo(password); + } + +} diff --git a/src/test/java/nextstep/app/LoginTest.java b/src/test/java/nextstep/app/LoginTest.java index 717bcc8..6946e6f 100644 --- a/src/test/java/nextstep/app/LoginTest.java +++ b/src/test/java/nextstep/app/LoginTest.java @@ -1,6 +1,9 @@ package nextstep.app; +import javax.servlet.http.HttpSession; + import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; import nextstep.app.infrastructure.InmemoryMemberRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -8,14 +11,14 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; -import javax.servlet.http.HttpSession; - import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @@ -26,6 +29,9 @@ class LoginTest { @Autowired private MockMvc mockMvc; + @Autowired + private MemberRepository memberRepository = new InmemoryMemberRepository(); + @DisplayName("로그인 성공") @Test void login_success() throws Exception { @@ -65,4 +71,24 @@ void login_fail_with_invalid_password() throws Exception { response.andExpect(status().isUnauthorized()); } + + @DisplayName("로그인 후 세션을 통해 회원 목록 조회") + @Test + void login_after_members() throws Exception { + var mockedSession = new MockHttpSession(); + ResultActions loginResponse = mockMvc.perform(post("/login") + .param("username", TEST_MEMBER.getEmail()) + .param("password", TEST_MEMBER.getPassword()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .session(mockedSession) + ).andDo(print()); + + loginResponse.andExpect(status().isOk()); + + ResultActions membersResponse = mockMvc.perform(get("/members") + .session(mockedSession) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ); + membersResponse.andExpect(status().isOk()); + } } diff --git a/src/test/java/nextstep/app/MemberTest.java b/src/test/java/nextstep/app/MemberTest.java index 58aba17..211963f 100644 --- a/src/test/java/nextstep/app/MemberTest.java +++ b/src/test/java/nextstep/app/MemberTest.java @@ -23,6 +23,7 @@ @SpringBootTest @AutoConfigureMockMvc class MemberTest { + private static final Member TEST_MEMBER = InmemoryMemberRepository.TEST_MEMBER_1; @Autowired