diff --git a/README.md b/README.md index 1e7ba652..4df1a6d9 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # spring-security-authentication + +## 기능 요구사항 +1. 아이디와 비밀번호 기반 로그인 구현 +- Basic 인증 구현 +- POST /login을 통해 로그인을 한다. +- 아이디/패스워드를 확인하여 인증한다 +- 인증에 성공하면 Session에 인증정보를 저장한다. +- Logniest의 모든 테스트를 통과하여야 한다. + +2. member 목록 조회 기능 구현 +- GET /member를 통해 멤버 목록을 조회한다. +- 조회 시 유저 인증이 되어있는지 먼저 확인힌다. +- Basic 인증을 이용하여 사용자를 식별한다. +- Authorization 헤더에서 Basic 인증정보를 추출하여 식별한다 +- 인증 성공 시 Session에 인증정보를 저장한다. +- MemberTest의 모든 테스트를 통과하여야 한다. + +3. 인증관련 로직을 Interceptor에서 구현 +- 인증 관련 로직을 Controller클래스에서 분리한다. +- 두 인증 방식을 모두 인터셉터에서 처리되도록 구현한다. +- 하나의 인터페이스는 하나의 작업만 수행하도록 설계한다. +- 기존 테스트케이스를 모두 통과하여야 한다. + +4. 인증 로직과 서비스 로직간의 패키지 분리 +- 서비스 관련 코드는 app, 인증 관련 코드는 security에 위치시킨다. +- 패키지간의 양방향 참조는 단방향으로 리팩토링한다. +- 인증 관련 작업은 security에서 전담하며, 서비스 로직이 인증 로직에 의존하지 않게 만든다. +- 모든 테스트케이스는 지속적으로 통과하여야 한다. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 99766160..91587f5b 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,13 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.apache.commons:commons-lang3:3.17.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + compileOnly "org.projectlombok:lombok" + testCompileOnly "org.projectlombok:lombok" + annotationProcessor "org.projectlombok:lombok" + testAnnotationProcessor "org.projectlombok:lombok" } tasks.named('test') { diff --git a/src/main/java/nextstep/app/SecurityAuthenticationApplication.java b/src/main/java/nextstep/SecurityAuthenticationApplication.java similarity index 93% rename from src/main/java/nextstep/app/SecurityAuthenticationApplication.java rename to src/main/java/nextstep/SecurityAuthenticationApplication.java index 0f8eb47d..1ecd05fe 100644 --- a/src/main/java/nextstep/app/SecurityAuthenticationApplication.java +++ b/src/main/java/nextstep/SecurityAuthenticationApplication.java @@ -1,4 +1,4 @@ -package nextstep.app; +package nextstep; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/src/main/java/nextstep/app/config/WebConfig.java b/src/main/java/nextstep/app/config/WebConfig.java new file mode 100644 index 00000000..31e5bcc1 --- /dev/null +++ b/src/main/java/nextstep/app/config/WebConfig.java @@ -0,0 +1,23 @@ +package nextstep.app.config; + +import lombok.RequiredArgsConstructor; +import nextstep.security.constants.SecurityConstants; +import nextstep.security.interceptor.BasicAuthInterceptor; +import nextstep.security.interceptor.UsernamePasswordInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + private final UsernamePasswordInterceptor usernamePasswordInterceptor; + private final BasicAuthInterceptor basicAuthInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { +// registry.addInterceptor(usernamePasswordInterceptor).addPathPatterns(SecurityConstants.LOGIN_URL); +// registry.addInterceptor(basicAuthInterceptor).excludePathPatterns(SecurityConstants.LOGIN_URL); + + } +} diff --git a/src/main/java/nextstep/app/domain/Member.java b/src/main/java/nextstep/app/domain/Member.java index 6cafa9c7..b7714d3f 100644 --- a/src/main/java/nextstep/app/domain/Member.java +++ b/src/main/java/nextstep/app/domain/Member.java @@ -1,31 +1,16 @@ package nextstep.app.domain; +import lombok.Data; +import nextstep.security.model.UserDetails; + +@Data public class Member { private final String email; private final String password; private final String name; private final String imageUrl; - public Member(String email, String password, String name, String imageUrl) { - this.email = email; - this.password = password; - this.name = name; - this.imageUrl = imageUrl; - } - - public String getEmail() { - return email; - } - - public String getPassword() { - return password; - } - - public String getName() { - return name; - } - - public String getImageUrl() { - return imageUrl; + public UserDetails getUserDetails() { + return new UserDetails(this.email, this.password); } } diff --git a/src/main/java/nextstep/app/ui/LoginController.java b/src/main/java/nextstep/app/ui/LoginController.java index 0ea94f1b..fd6a87bf 100644 --- a/src/main/java/nextstep/app/ui/LoginController.java +++ b/src/main/java/nextstep/app/ui/LoginController.java @@ -1,27 +1,17 @@ package nextstep.app.ui; -import nextstep.app.domain.MemberRepository; +import lombok.RequiredArgsConstructor; 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; - +@RequiredArgsConstructor @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(); } diff --git a/src/main/java/nextstep/app/ui/MemberController.java b/src/main/java/nextstep/app/ui/MemberController.java index c8cc74d6..8a218ad2 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -1,26 +1,32 @@ package nextstep.app.ui; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; 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; +@Slf4j +@RequiredArgsConstructor @RestController 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/.DS_Store b/src/main/java/nextstep/security/.DS_Store new file mode 100644 index 00000000..93b4855e Binary files /dev/null and b/src/main/java/nextstep/security/.DS_Store differ diff --git a/src/main/java/nextstep/security/config/SecurityConfig.java b/src/main/java/nextstep/security/config/SecurityConfig.java new file mode 100644 index 00000000..e0202e2b --- /dev/null +++ b/src/main/java/nextstep/security/config/SecurityConfig.java @@ -0,0 +1,37 @@ +package nextstep.security.config; + +import lombok.RequiredArgsConstructor; +import nextstep.security.filters.*; +import nextstep.security.service.UserDetailService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final UserDetailService userDetailService; + + @Bean + public DelegatingFilterProxy delegatingFilterProxy() { + return new DelegatingFilterProxy(filterChainProxy(List.of(securityFilterChain()))); + } + + @Bean + public SecurityFilterChain securityFilterChain() { + return new DefaultSecurityFilterChain( + List.of( + new SecurityContextHolderFilter(), + new UsernamePasswordAuthFilter(userDetailService), + new BasicAuthenticationFilter(userDetailService) + ) + ); + } + + + private FilterChainProxy filterChainProxy(List securityFilterChains) { + return new FilterChainProxy(securityFilterChains); + } +} diff --git a/src/main/java/nextstep/security/constants/SecurityConstants.java b/src/main/java/nextstep/security/constants/SecurityConstants.java new file mode 100644 index 00000000..63d9faf9 --- /dev/null +++ b/src/main/java/nextstep/security/constants/SecurityConstants.java @@ -0,0 +1,9 @@ +package nextstep.security.constants; + +public class SecurityConstants { + public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; + public static final String USERNAME = "username"; + public static final String PASSWORD = "password"; + public static final String LOGIN_URL = "/login"; + public static final String AUTHENTICATION_SCHEME_BASIC = "Basic "; +} diff --git a/src/main/java/nextstep/security/context/HttpSessionSecurityContextRepository.java b/src/main/java/nextstep/security/context/HttpSessionSecurityContextRepository.java new file mode 100644 index 00000000..373a91b9 --- /dev/null +++ b/src/main/java/nextstep/security/context/HttpSessionSecurityContextRepository.java @@ -0,0 +1,21 @@ +package nextstep.security.context; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import static nextstep.security.constants.SecurityConstants.SPRING_SECURITY_CONTEXT_KEY; + +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/context/SecurityContext.java b/src/main/java/nextstep/security/context/SecurityContext.java new file mode 100644 index 00000000..862c81bd --- /dev/null +++ b/src/main/java/nextstep/security/context/SecurityContext.java @@ -0,0 +1,15 @@ +package nextstep.security.context; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import nextstep.security.model.SecurityAuthentication; + +import java.io.Serializable; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class SecurityContext implements Serializable { + private SecurityAuthentication securityAuthentication; +} diff --git a/src/main/java/nextstep/security/context/SecurityContextHolder.java b/src/main/java/nextstep/security/context/SecurityContextHolder.java new file mode 100644 index 00000000..d074c533 --- /dev/null +++ b/src/main/java/nextstep/security/context/SecurityContextHolder.java @@ -0,0 +1,30 @@ +package nextstep.security.context; + +public class SecurityContextHolder { + private static ThreadLocal contextHolder; + + static { + contextHolder = new ThreadLocal<>(); + } + + public static SecurityContext getContext() { + SecurityContext ctx = contextHolder.get(); + if (ctx == null) { + ctx = createEmptyContext(); + contextHolder.set(ctx); + } + return ctx; + } + public static void setContext(SecurityContext context) { + if (context != null) { + contextHolder.set(context); + } + } + public static SecurityContext createEmptyContext() { + return new SecurityContext(); + } + + public static void clearContext() { + contextHolder.remove(); + } +} diff --git a/src/main/java/nextstep/security/credential/UsernamePasswordAuthenticationToken.java b/src/main/java/nextstep/security/credential/UsernamePasswordAuthenticationToken.java new file mode 100644 index 00000000..4e37fe4c --- /dev/null +++ b/src/main/java/nextstep/security/credential/UsernamePasswordAuthenticationToken.java @@ -0,0 +1,18 @@ +package nextstep.security.credential; + +import lombok.Data; +import nextstep.security.model.SecurityAuthentication; + +@Data +public class UsernamePasswordAuthenticationToken implements SecurityAuthentication { + private final Object principal; + private final Object credentials; + private final boolean authenticated; + + public static UsernamePasswordAuthenticationToken unauthenticated(String principal, String credentials) { + return new UsernamePasswordAuthenticationToken(principal, credentials, false); + } + public static UsernamePasswordAuthenticationToken authenticated(String principal, String credentials) { + return new UsernamePasswordAuthenticationToken(principal, credentials, true); + } +} diff --git a/src/main/java/nextstep/security/filters/BasicAuthenticationFilter.java b/src/main/java/nextstep/security/filters/BasicAuthenticationFilter.java new file mode 100644 index 00000000..6e5c4066 --- /dev/null +++ b/src/main/java/nextstep/security/filters/BasicAuthenticationFilter.java @@ -0,0 +1,81 @@ +package nextstep.security.filters; + +import nextstep.app.ui.AuthenticationException; +import nextstep.security.constants.SecurityConstants; +import nextstep.security.credential.UsernamePasswordAuthenticationToken; +import nextstep.security.model.SecurityAuthentication; +import nextstep.security.provider.AuthenticationManager; +import nextstep.security.provider.ProviderManager; +import nextstep.security.provider.UsernameProvider; +import nextstep.security.service.UserDetailService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +public class BasicAuthenticationFilter extends OncePerRequestFilter { + private final AuthenticationManager authenticationManager; + + public BasicAuthenticationFilter(UserDetailService userDetailsService) { + this.authenticationManager = new ProviderManager( + List.of(new UsernameProvider(userDetailsService)) + ); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + SecurityAuthentication authentication = convert(request); + if (authentication == null) { + filterChain.doFilter(request, response); + return; + } + this.authenticationManager.authenticate(authentication); + filterChain.doFilter(request, response); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private SecurityAuthentication convert(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header == null) { + return null; + } + + header = header.trim(); + if (!StringUtils.startsWithIgnoreCase(header, SecurityConstants.AUTHENTICATION_SCHEME_BASIC)) { + return null; + } + if (header.equalsIgnoreCase(SecurityConstants.AUTHENTICATION_SCHEME_BASIC)) { + throw new AuthenticationException(); + } + + byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8); + byte[] decoded = decode(base64Token); + String token = new String(decoded, StandardCharsets.UTF_8); + int delim = token.indexOf(":"); + if (delim == -1) { + throw new AuthenticationException(); + } + + return UsernamePasswordAuthenticationToken + .unauthenticated(token.substring(0, delim), token.substring(delim + 1)); + } + + private byte[] decode(byte[] base64Token) { + try { + return Base64.getDecoder().decode(base64Token); + } catch (IllegalArgumentException ex) { + throw new AuthenticationException(); + } + } +} diff --git a/src/main/java/nextstep/security/filters/DefaultSecurityFilterChain.java b/src/main/java/nextstep/security/filters/DefaultSecurityFilterChain.java new file mode 100644 index 00000000..2d351e17 --- /dev/null +++ b/src/main/java/nextstep/security/filters/DefaultSecurityFilterChain.java @@ -0,0 +1,20 @@ +package nextstep.security.filters; + +import javax.servlet.Filter; +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +public class DefaultSecurityFilterChain implements SecurityFilterChain { + private final List filters; + public DefaultSecurityFilterChain(List filters) { + this.filters = filters; + } + @Override + public boolean matches(HttpServletRequest request) { + return true; + } + @Override + public List getFilters() { + return filters; + } +} diff --git a/src/main/java/nextstep/security/filters/DelegatingFilterProxy.java b/src/main/java/nextstep/security/filters/DelegatingFilterProxy.java new file mode 100644 index 00000000..8bba65fe --- /dev/null +++ b/src/main/java/nextstep/security/filters/DelegatingFilterProxy.java @@ -0,0 +1,17 @@ +package nextstep.security.filters; + +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.*; +import java.io.IOException; + +public class DelegatingFilterProxy extends GenericFilterBean { + private final Filter delegate; + public DelegatingFilterProxy(Filter delegate) { + this.delegate = delegate; + } + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + delegate.doFilter(request, response, chain); + } +} \ No newline at end of file diff --git a/src/main/java/nextstep/security/filters/FilterChainProxy.java b/src/main/java/nextstep/security/filters/FilterChainProxy.java new file mode 100644 index 00000000..f92013e2 --- /dev/null +++ b/src/main/java/nextstep/security/filters/FilterChainProxy.java @@ -0,0 +1,49 @@ +package nextstep.security.filters; + +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.List; +public class FilterChainProxy extends GenericFilterBean { + private final List filterChains; + public FilterChainProxy(List filterChains) { + this.filterChains = filterChains; + } + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + List filters = getFilters((HttpServletRequest) request); + VirtualFilterChain virtualFilterChain = new VirtualFilterChain(chain, filters); + virtualFilterChain.doFilter(request, response); + } + private List getFilters(HttpServletRequest request) { + for (SecurityFilterChain chain : this.filterChains) { + if (chain.matches(request)) { + return chain.getFilters(); + } + } + return null; + } + private static final class VirtualFilterChain implements FilterChain { + private final FilterChain originalChain; + private final List additionalFilters; + private final int size; + private int currentPosition = 0; + private VirtualFilterChain(FilterChain chain, List additionalFilters) { + this.originalChain = chain; + this.additionalFilters = additionalFilters; + this.size = additionalFilters.size(); + } + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { + if (this.currentPosition == this.size) { + this.originalChain.doFilter(request, response); + return; + } + this.currentPosition++; + Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1); + nextFilter.doFilter(request, response, this); + } + } +} \ No newline at end of file diff --git a/src/main/java/nextstep/security/filters/SecurityContextHolderFilter.java b/src/main/java/nextstep/security/filters/SecurityContextHolderFilter.java new file mode 100644 index 00000000..1d9709b2 --- /dev/null +++ b/src/main/java/nextstep/security/filters/SecurityContextHolderFilter.java @@ -0,0 +1,27 @@ +package nextstep.security.filters; + +import nextstep.security.context.HttpSessionSecurityContextRepository; +import nextstep.security.context.SecurityContext; +import nextstep.security.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +public class SecurityContextHolderFilter extends GenericFilterBean { + + private final HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + SecurityContext context = this.securityContextRepository.loadContext((HttpServletRequest) servletRequest); + SecurityContextHolder.setContext(context); + + filterChain.doFilter(servletRequest, servletResponse); + + SecurityContextHolder.clearContext(); + + + } +} diff --git a/src/main/java/nextstep/security/filters/SecurityFilterChain.java b/src/main/java/nextstep/security/filters/SecurityFilterChain.java new file mode 100644 index 00000000..0da4dd59 --- /dev/null +++ b/src/main/java/nextstep/security/filters/SecurityFilterChain.java @@ -0,0 +1,12 @@ +package nextstep.security.filters; + +import javax.servlet.Filter; +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +public interface SecurityFilterChain { + + boolean matches(HttpServletRequest request); + + List getFilters(); +} diff --git a/src/main/java/nextstep/security/filters/UsernamePasswordAuthFilter.java b/src/main/java/nextstep/security/filters/UsernamePasswordAuthFilter.java new file mode 100644 index 00000000..2fc19184 --- /dev/null +++ b/src/main/java/nextstep/security/filters/UsernamePasswordAuthFilter.java @@ -0,0 +1,65 @@ +package nextstep.security.filters; + + +import nextstep.security.constants.SecurityConstants; +import nextstep.security.credential.UsernamePasswordAuthenticationToken; +import nextstep.security.model.SecurityAuthentication; +import nextstep.security.provider.AuthenticationManager; +import nextstep.security.provider.ProviderManager; +import nextstep.security.provider.UsernameProvider; +import nextstep.security.service.UserDetailService; +import org.springframework.web.filter.GenericFilterBean; + +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 javax.servlet.http.HttpSession; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public class UsernamePasswordAuthFilter extends GenericFilterBean { + private final AuthenticationManager authenticationManager; + + public UsernamePasswordAuthFilter(UserDetailService userDetailsService) { + this.authenticationManager = new ProviderManager( + List.of(new UsernameProvider(userDetailsService)) + ); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (!SecurityConstants.LOGIN_URL.equals(((HttpServletRequest) request).getRequestURI())) { + chain.doFilter(request, response); + return; + } + try { + SecurityAuthentication authentication = convert(request); + if (authentication == null) { + chain.doFilter(request, response); + return; + } + + SecurityAuthentication authenticate = this.authenticationManager.authenticate(authentication); + HttpSession session = ((HttpServletRequest) request).getSession(); + session.setAttribute(SecurityConstants.SPRING_SECURITY_CONTEXT_KEY, authenticate); + } catch ( + Exception e) { + ((HttpServletResponse) response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private SecurityAuthentication convert(ServletRequest request) { + try { + Map parameterMap = request.getParameterMap(); + String username = parameterMap.get("username")[0]; + String password = parameterMap.get("password")[0]; + return UsernamePasswordAuthenticationToken.unauthenticated(username, password); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/nextstep/security/interceptor/.DS_Store b/src/main/java/nextstep/security/interceptor/.DS_Store new file mode 100644 index 00000000..d5775675 Binary files /dev/null and b/src/main/java/nextstep/security/interceptor/.DS_Store differ diff --git a/src/main/java/nextstep/security/interceptor/BasicAuthInterceptor.java b/src/main/java/nextstep/security/interceptor/BasicAuthInterceptor.java new file mode 100644 index 00000000..54f74755 --- /dev/null +++ b/src/main/java/nextstep/security/interceptor/BasicAuthInterceptor.java @@ -0,0 +1,59 @@ +package nextstep.security.interceptor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nextstep.security.model.UserDetails; +import nextstep.security.service.UserDetailService; +import nextstep.app.ui.AuthenticationException; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; + +@Slf4j +@RequiredArgsConstructor +@Component +public class BasicAuthInterceptor implements HandlerInterceptor { + private final UserDetailService userDetailService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (!authenticate(authorization)) { + throw new AuthenticationException(); + } + return true; + } + + private boolean authenticate(String authorization) { + if (StringUtils.isEmpty(authorization)) { + return false; + } + + if (!StringUtils.startsWith(authorization, "Basic ")) { + return false; + } + + String encodedCredentials = authorization.substring("Basic ".length()); + String decodedCredentials = new String(Base64Utils.decodeFromString(encodedCredentials), StandardCharsets.UTF_8); + + String[] userDetail = decodedCredentials.split(":",2); + + if (userDetail.length != 2) { + return false; + } + + UserDetails loginMember = userDetailService.getUserDetails(userDetail[0], userDetail[1]); + if (loginMember == null) { + return false; + } + + log.info("Login success. ID : {} / password : {}", loginMember.getUsername(), loginMember.getPassword()); + return true; + } +} diff --git a/src/main/java/nextstep/security/interceptor/UsernamePasswordInterceptor.java b/src/main/java/nextstep/security/interceptor/UsernamePasswordInterceptor.java new file mode 100644 index 00000000..8fc38d30 --- /dev/null +++ b/src/main/java/nextstep/security/interceptor/UsernamePasswordInterceptor.java @@ -0,0 +1,41 @@ +package nextstep.security.interceptor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nextstep.security.model.UserDetails; +import nextstep.security.service.UserDetailService; +import nextstep.app.ui.AuthenticationException; +import nextstep.security.constants.SecurityConstants; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +@Slf4j +@RequiredArgsConstructor +@Component +public class UsernamePasswordInterceptor implements HandlerInterceptor { + private final UserDetailService userDetailService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + HttpSession session = request.getSession(); + + if (session.getAttribute(SecurityConstants.SPRING_SECURITY_CONTEXT_KEY) == null) { + String username = request.getParameter(SecurityConstants.USERNAME); + String password = request.getParameter(SecurityConstants.PASSWORD); + + UserDetails userDetails = userDetailService.getUserDetails(username, password); + + if (userDetails == null) { + throw new AuthenticationException(); + } + + session.setAttribute(SecurityConstants.SPRING_SECURITY_CONTEXT_KEY, userDetails); + } + + return true; + } +} diff --git a/src/main/java/nextstep/security/model/SecurityAuthentication.java b/src/main/java/nextstep/security/model/SecurityAuthentication.java new file mode 100644 index 00000000..9b37711e --- /dev/null +++ b/src/main/java/nextstep/security/model/SecurityAuthentication.java @@ -0,0 +1,8 @@ +package nextstep.security.model; + +public interface SecurityAuthentication { + // Collection getAuthorities(); + Object getCredentials(); + Object getPrincipal(); + boolean isAuthenticated(); +} 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 00000000..d73c6112 --- /dev/null +++ b/src/main/java/nextstep/security/model/UserDetails.java @@ -0,0 +1,11 @@ +package nextstep.security.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserDetails { + private String username; + private String password; +} diff --git a/src/main/java/nextstep/security/provider/AuthenticationManager.java b/src/main/java/nextstep/security/provider/AuthenticationManager.java new file mode 100644 index 00000000..f0612e66 --- /dev/null +++ b/src/main/java/nextstep/security/provider/AuthenticationManager.java @@ -0,0 +1,7 @@ +package nextstep.security.provider; + +import nextstep.security.model.SecurityAuthentication; + +public interface AuthenticationManager { + SecurityAuthentication authenticate(SecurityAuthentication authentication); +} diff --git a/src/main/java/nextstep/security/provider/AuthenticationProvider.java b/src/main/java/nextstep/security/provider/AuthenticationProvider.java new file mode 100644 index 00000000..a2a9ff35 --- /dev/null +++ b/src/main/java/nextstep/security/provider/AuthenticationProvider.java @@ -0,0 +1,9 @@ +package nextstep.security.provider; + +import nextstep.security.model.SecurityAuthentication; + +public interface AuthenticationProvider { + SecurityAuthentication authenticate(SecurityAuthentication authentication); + + boolean supports(Class authentication); +} diff --git a/src/main/java/nextstep/security/provider/ProviderManager.java b/src/main/java/nextstep/security/provider/ProviderManager.java new file mode 100644 index 00000000..151bc1b3 --- /dev/null +++ b/src/main/java/nextstep/security/provider/ProviderManager.java @@ -0,0 +1,21 @@ +package nextstep.security.provider; + +import lombok.RequiredArgsConstructor; +import nextstep.security.model.SecurityAuthentication; + +import java.util.List; + +@RequiredArgsConstructor +public class ProviderManager implements AuthenticationManager { + private final List providers; + + @Override + public SecurityAuthentication authenticate(SecurityAuthentication authentication) { + for (AuthenticationProvider provider : providers) { + if (provider.supports(authentication.getClass())) { + return provider.authenticate(authentication); + } + } + return null; + } +} diff --git a/src/main/java/nextstep/security/provider/UsernameProvider.java b/src/main/java/nextstep/security/provider/UsernameProvider.java new file mode 100644 index 00000000..016f5251 --- /dev/null +++ b/src/main/java/nextstep/security/provider/UsernameProvider.java @@ -0,0 +1,29 @@ +package nextstep.security.provider; + +import lombok.RequiredArgsConstructor; +import nextstep.app.ui.AuthenticationException; +import nextstep.security.credential.UsernamePasswordAuthenticationToken; +import nextstep.security.model.SecurityAuthentication; +import nextstep.security.model.UserDetails; +import nextstep.security.service.UserDetailService; + +import java.util.Objects; + +@RequiredArgsConstructor +public class UsernameProvider implements AuthenticationProvider { + private final UserDetailService userDetailService; + + @Override + public SecurityAuthentication authenticate(SecurityAuthentication authentication) { + UserDetails userDetails = userDetailService.loadUserByUsername(authentication.getPrincipal().toString()); + if (!Objects.equals(userDetails.getPassword(), authentication.getCredentials())) { + throw new AuthenticationException(); + } + 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/UserDetailService.java b/src/main/java/nextstep/security/service/UserDetailService.java new file mode 100644 index 00000000..777f6486 --- /dev/null +++ b/src/main/java/nextstep/security/service/UserDetailService.java @@ -0,0 +1,9 @@ +package nextstep.security.service; + +import nextstep.security.model.UserDetails; + +public interface UserDetailService { + UserDetails getUserDetails(String username, String password); + + UserDetails loadUserByUsername(String email); +} diff --git a/src/main/java/nextstep/security/service/UserDetailServiceImpl.java b/src/main/java/nextstep/security/service/UserDetailServiceImpl.java new file mode 100644 index 00000000..9cef49b8 --- /dev/null +++ b/src/main/java/nextstep/security/service/UserDetailServiceImpl.java @@ -0,0 +1,45 @@ +package nextstep.security.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; +import nextstep.security.model.UserDetails; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserDetailServiceImpl implements UserDetailService { + private final MemberRepository memberRepository; + + @Override + public UserDetails getUserDetails(String username, String password) { + Member member = memberRepository.findByEmail(username).orElse(null); + + if (member == null) { + log.warn("Member is not exists"); + return null; + } + + if (!StringUtils.equals(member.getPassword(), password)) { + log.warn("Member is not valid"); + return null; + } + + return member.getUserDetails(); + } + + @Override + public UserDetails loadUserByUsername(String email) { + Member member = memberRepository.findByEmail(email).orElse(null); + + if (member == null) { + log.warn("Member is not exists"); + return null; + } + + return member.getUserDetails(); + } +} diff --git a/src/test/java/nextstep/app/LoginTest.java b/src/test/java/nextstep/app/LoginTest.java index 717bcc8a..19b10f8a 100644 --- a/src/test/java/nextstep/app/LoginTest.java +++ b/src/test/java/nextstep/app/LoginTest.java @@ -12,10 +12,13 @@ import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; +import javax.servlet.http.Cookie; 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 @@ -65,4 +68,23 @@ void login_fail_with_invalid_password() throws Exception { response.andExpect(status().isUnauthorized()); } + + @DisplayName("로그인 후 세션을 통해 회원 목록 조회") + @Test + void login_after_members() throws Exception { + ResultActions loginResponse = mockMvc.perform(post("/login") + .param("username", TEST_MEMBER.getEmail()) + .param("password", TEST_MEMBER.getPassword()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ).andDo(print()); + loginResponse.andExpect(status().isOk()); + MvcResult loginResult = loginResponse.andReturn(); + HttpSession session = loginResult.getRequest().getSession(); + String sessionId = session.getId(); + ResultActions membersResponse = mockMvc.perform(get("/members") + .cookie(new Cookie("JSESSIONID", sessionId)) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ); + membersResponse.andExpect(status().isOk()); + } }