diff --git a/README.md b/README.md index 1e7ba652..c342ba83 100644 --- a/README.md +++ b/README.md @@ -1 +1,26 @@ # spring-security-authentication + +## 기능 요구 사항 +- 아이디/비밀번호 기반 로그인 구현 + + ```POST/login``` 경로로 로그인 요청 + + 사용자가 입력한 아이디와 비밀번호를 확인하여 인증 + + 로그인 성공 시 Session 을 사용하여 인증 정보를 저장 + + ```LoginTest```의 모든 테스트가 통과해야 한다. +- Basic 인증 구현 + + ```GET /member``` 요청 시 사용자 목록을 조회한다. + + 단, ```Member```로 등록되어있는 사용자만 가능하도록 한다. + + 이를 위해 Basic 인증을 사용하여 사용자를 식별한다. + + 요청의 Authorization 헤더에서 Basic 인증 정보를 추출하여 인증을 처리한다. + + 인증 성공 시 을 사용하여 인증 정보를 저장한다. + + ```MemberTest```의 모든 테스트가 통과해야 한다. +- 인터셉터 분리 + + ```HandlerInterceptor```를 사용하여 인증 관련 로직을 Controller 클래스에서 분리한다. + - 앞서 구현한 두 인증 방식(아이디 비밀번호 로그인 방식과 Basic 인증 방식) 모두 인터셉터에서 처리되도록 구현한다. + - 가급적이면 하나의 인터셉터는 하나의 작업만 수행하도록 설계한다. +- 인증 로직과 서비스 로직 간의 패키지 분리 + + 서비스 코드와 인증 코드를 명확히 분리하여 관리하도록 한다. + - 서비스 관련 코드는 ```app``` 패키지에 위치시키고, 인증 관련 코드는 ```security``` 패키지에 위치시킨다. + + 리팩터링 과정에서 패키지 간의 양방향 참조가 발생한다면 단방향 참조로 리팩터링한다. + - ```app``` 패키지는 ```security``` 패키지에 의존할 수 있지만, 반대로 ```security``` 패키지는 ```app``` 패키지에 의존하지 않도록 한다. + + 인증 관련 작업은 ```security``` 패키지에서 전담하도록 설계하여, 서비스 로직이 인증 세부 사항에 의존하지 않게 만든다. + + ```LoginTest```와 ```MemberTest```의 모든 테스트는 지속해서 통과해야 한다. \ No newline at end of file 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..de8320a6 --- /dev/null +++ b/src/main/java/nextstep/app/config/WebConfig.java @@ -0,0 +1,27 @@ +package nextstep.app.config; + +import nextstep.security.interceptor.BasicAuthenticationInterceptor; +import nextstep.security.interceptor.FormLoginAuthenticationInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final FormLoginAuthenticationInterceptor formLoginAuthenticationInterceptor; + private final BasicAuthenticationInterceptor basicAuthenticationInterceptor; + + public WebConfig(FormLoginAuthenticationInterceptor formLoginAuthenticationInterceptor, + BasicAuthenticationInterceptor basicAuthenticationInterceptor) { + this.formLoginAuthenticationInterceptor = formLoginAuthenticationInterceptor; + this.basicAuthenticationInterceptor = basicAuthenticationInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(basicAuthenticationInterceptor) + .addPathPatterns("/members"); + + registry.addInterceptor(formLoginAuthenticationInterceptor); + } +} diff --git a/src/main/java/nextstep/app/domain/UserDetailsImpl.java b/src/main/java/nextstep/app/domain/UserDetailsImpl.java new file mode 100644 index 00000000..584b6ad6 --- /dev/null +++ b/src/main/java/nextstep/app/domain/UserDetailsImpl.java @@ -0,0 +1,40 @@ +package nextstep.app.domain; + +import nextstep.security.domain.UserDetails; + +public class UserDetailsImpl implements UserDetails { + private final String email; + private final String password; + + private UserDetailsImpl(String email, String password) { + this.email = email; + this.password = password; + } + + public static UserDetailsImpl of(Member member) { + return new UserDetailsImpl(member.getEmail(), member.getPassword()); + } + + public static UserDetailsImpl empty() { + return new UserDetailsImpl(null, null); + } + + @Override + public String getUsername() { + return email; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public boolean isEmpty() { + return email == null && password == null; + } + + public boolean verifyPassword(String password) { + return this.password.equals(password); + } +} diff --git a/src/main/java/nextstep/app/domain/UserDetailsServiceImpl.java b/src/main/java/nextstep/app/domain/UserDetailsServiceImpl.java new file mode 100644 index 00000000..d323bcdb --- /dev/null +++ b/src/main/java/nextstep/app/domain/UserDetailsServiceImpl.java @@ -0,0 +1,29 @@ +package nextstep.app.domain; + +import nextstep.security.domain.UserDetails; +import nextstep.security.domain.UserDetailsService; +import org.springframework.stereotype.Service; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + private final MemberRepository memberRepository; + + public UserDetailsServiceImpl(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Override + public UserDetails loadUserByUsernameAndPassword(String username, String password) { + return memberRepository.findByEmail(username) + .map(UserDetailsImpl::of) + .filter(userDetails -> userDetails.verifyPassword(password)) + .orElse(UserDetailsImpl.empty()); + } + + @Override + public UserDetails loadUserByUsername(String username) { + return memberRepository.findByEmail(username) + .map(UserDetailsImpl::of) + .orElse(null); + } +} diff --git a/src/main/java/nextstep/app/ui/LoginController.java b/src/main/java/nextstep/app/ui/LoginController.java index 0ea94f1b..7fddf9c5 100644 --- a/src/main/java/nextstep/app/ui/LoginController.java +++ b/src/main/java/nextstep/app/ui/LoginController.java @@ -1,9 +1,7 @@ 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; @@ -12,8 +10,6 @@ @RestController public class LoginController { - public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; - private final MemberRepository memberRepository; public LoginController(MemberRepository memberRepository) { @@ -24,9 +20,4 @@ public LoginController(MemberRepository memberRepository) { public ResponseEntity login(HttpServletRequest request, HttpSession session) { return ResponseEntity.ok().build(); } - - @ExceptionHandler(AuthenticationException.class) - 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 c8cc74d6..0f706d14 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -10,7 +10,6 @@ @RestController public class MemberController { - private final MemberRepository memberRepository; public MemberController(MemberRepository memberRepository) { @@ -22,5 +21,4 @@ public ResponseEntity> list() { List members = memberRepository.findAll(); return ResponseEntity.ok(members); } - } diff --git a/src/main/java/nextstep/security/authentication/Authentication.java b/src/main/java/nextstep/security/authentication/Authentication.java new file mode 100644 index 00000000..117632b7 --- /dev/null +++ b/src/main/java/nextstep/security/authentication/Authentication.java @@ -0,0 +1,7 @@ +package nextstep.security.authentication; + +public interface Authentication { + Object getCredentials(); + Object getPrincipal(); + boolean isAuthenticated(); +} diff --git a/src/main/java/nextstep/security/authentication/AuthenticationManager.java b/src/main/java/nextstep/security/authentication/AuthenticationManager.java new file mode 100644 index 00000000..ac74b1eb --- /dev/null +++ b/src/main/java/nextstep/security/authentication/AuthenticationManager.java @@ -0,0 +1,5 @@ +package nextstep.security.authentication; + +public interface AuthenticationManager { + Authentication authenticate(Authentication authentication); +} diff --git a/src/main/java/nextstep/security/authentication/AuthenticationProvider.java b/src/main/java/nextstep/security/authentication/AuthenticationProvider.java new file mode 100644 index 00000000..bf1e940d --- /dev/null +++ b/src/main/java/nextstep/security/authentication/AuthenticationProvider.java @@ -0,0 +1,8 @@ +package nextstep.security.authentication; + +import nextstep.security.exception.AuthenticationException; + +public interface AuthenticationProvider { + Authentication authenticate(Authentication authentication) throws AuthenticationException; + boolean supports(Class authentication); +} diff --git a/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java b/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java new file mode 100644 index 00000000..7ccb8b1b --- /dev/null +++ b/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java @@ -0,0 +1,31 @@ +package nextstep.security.authentication; + +import nextstep.security.domain.UserDetails; +import nextstep.security.domain.UserDetailsService; +import nextstep.security.exception.AuthenticationException; + +import java.util.Objects; + +public class DaoAuthenticationProvider implements AuthenticationProvider { + + private final UserDetailsService userDetailsService; + + public DaoAuthenticationProvider(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + UserDetails userDetails = userDetailsService.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/authentication/ProviderManager.java b/src/main/java/nextstep/security/authentication/ProviderManager.java new file mode 100644 index 00000000..d6dae866 --- /dev/null +++ b/src/main/java/nextstep/security/authentication/ProviderManager.java @@ -0,0 +1,23 @@ +package nextstep.security.authentication; + +import java.util.List; + +public class ProviderManager implements AuthenticationManager { + + private final List providers; + + public ProviderManager(List providers) { + this.providers = providers; + } + + @Override + public Authentication authenticate(Authentication authentication) { + for (AuthenticationProvider provider : providers) { + if (provider.supports(authentication.getClass())) { + return provider.authenticate(authentication); + } + } + + return null; + } +} diff --git a/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java b/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java new file mode 100644 index 00000000..375ff8e7 --- /dev/null +++ b/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java @@ -0,0 +1,38 @@ +package nextstep.security.authentication; + +public class UsernamePasswordAuthenticationToken implements Authentication { + + private final Object principal; + private final Object credentials; + private final boolean authenticated; + + public UsernamePasswordAuthenticationToken(Object principal, Object credentials, boolean authenticated) { + this.principal = principal; + this.credentials = credentials; + this.authenticated = 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); + } + + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } +} diff --git a/src/main/java/nextstep/security/config/DefaultSecurityFilterChain.java b/src/main/java/nextstep/security/config/DefaultSecurityFilterChain.java new file mode 100644 index 00000000..b211b0c2 --- /dev/null +++ b/src/main/java/nextstep/security/config/DefaultSecurityFilterChain.java @@ -0,0 +1,23 @@ +package nextstep.security.config; + +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/config/DelegatingFilterProxy.java b/src/main/java/nextstep/security/config/DelegatingFilterProxy.java new file mode 100644 index 00000000..714cbbe0 --- /dev/null +++ b/src/main/java/nextstep/security/config/DelegatingFilterProxy.java @@ -0,0 +1,19 @@ +package nextstep.security.config; + +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 servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + delegate.doFilter(servletRequest, servletResponse, filterChain); + } +} diff --git a/src/main/java/nextstep/security/config/FilterChainProxy.java b/src/main/java/nextstep/security/config/FilterChainProxy.java new file mode 100644 index 00000000..95ae4dbd --- /dev/null +++ b/src/main/java/nextstep/security/config/FilterChainProxy.java @@ -0,0 +1,59 @@ +package nextstep.security.config; + +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 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 request) { + for (SecurityFilterChain filterChain : filterChains) { + if (filterChain.matches(request)) { + return filterChain.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; + + public VirtualFilterChain(FilterChain originalChain, List additionalFilters) { + this.originalChain = originalChain; + this.additionalFilters = additionalFilters; + this.size = additionalFilters.size(); + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse) throws IOException, ServletException { + if (currentPosition == size) { + originalChain.doFilter(servletRequest, servletResponse); + return; + } + this.currentPosition++; + Filter nextFilter = additionalFilters.get(currentPosition - 1); + nextFilter.doFilter(servletRequest, servletResponse, this); + } + } +} 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..8c8b8fc0 --- /dev/null +++ b/src/main/java/nextstep/security/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package nextstep.security.config; + +import nextstep.security.domain.UserDetailsService; +import nextstep.security.filter.BasicAuthenticationFilter; +import nextstep.security.filter.UsernamePasswordAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SecurityConfig { + private final UserDetailsService userDetailsService; + + public SecurityConfig(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Bean + public DelegatingFilterProxy delegatingFilterProxy() { + return new DelegatingFilterProxy(filterChainProxy(List.of(securityFilterChain()))); + } + + @Bean + public FilterChainProxy filterChainProxy(List securityFilterChains) { + return new FilterChainProxy(securityFilterChains); + } + + @Bean + public SecurityFilterChain securityFilterChain() { + return new DefaultSecurityFilterChain( + List.of( + new UsernamePasswordAuthenticationFilter(userDetailsService), + new BasicAuthenticationFilter(userDetailsService) + ) + ); + } +} diff --git a/src/main/java/nextstep/security/config/SecurityFilterChain.java b/src/main/java/nextstep/security/config/SecurityFilterChain.java new file mode 100644 index 00000000..dc44b215 --- /dev/null +++ b/src/main/java/nextstep/security/config/SecurityFilterChain.java @@ -0,0 +1,10 @@ +package nextstep.security.config; + +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/context/SecurityContextHolder.java b/src/main/java/nextstep/security/context/SecurityContextHolder.java new file mode 100644 index 00000000..dd0b99a6 --- /dev/null +++ b/src/main/java/nextstep/security/context/SecurityContextHolder.java @@ -0,0 +1,27 @@ +package nextstep.security.context; + +import nextstep.security.domain.UserDetails; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +public final class SecurityContextHolder { + public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; + + public static UserDetails getUserDetails() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return null; + } + + return (UserDetails) attributes.getAttribute(SPRING_SECURITY_CONTEXT_KEY, RequestAttributes.SCOPE_SESSION); + } + + public static void setUserDetails(UserDetails userDetails) { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return; + } + + attributes.setAttribute(SPRING_SECURITY_CONTEXT_KEY, userDetails, RequestAttributes.SCOPE_SESSION); + } +} diff --git a/src/main/java/nextstep/security/domain/UserDetails.java b/src/main/java/nextstep/security/domain/UserDetails.java new file mode 100644 index 00000000..9a0eed57 --- /dev/null +++ b/src/main/java/nextstep/security/domain/UserDetails.java @@ -0,0 +1,7 @@ +package nextstep.security.domain; + +public interface UserDetails { + String getUsername(); + String getPassword(); + boolean isEmpty(); +} diff --git a/src/main/java/nextstep/security/domain/UserDetailsService.java b/src/main/java/nextstep/security/domain/UserDetailsService.java new file mode 100644 index 00000000..d57d47da --- /dev/null +++ b/src/main/java/nextstep/security/domain/UserDetailsService.java @@ -0,0 +1,6 @@ +package nextstep.security.domain; + +public interface UserDetailsService { + UserDetails loadUserByUsernameAndPassword(String username, String password); + UserDetails loadUserByUsername(String username); +} diff --git a/src/main/java/nextstep/app/ui/AuthenticationException.java b/src/main/java/nextstep/security/exception/AuthenticationException.java similarity index 64% rename from src/main/java/nextstep/app/ui/AuthenticationException.java rename to src/main/java/nextstep/security/exception/AuthenticationException.java index f809b6e4..1271b52d 100644 --- a/src/main/java/nextstep/app/ui/AuthenticationException.java +++ b/src/main/java/nextstep/security/exception/AuthenticationException.java @@ -1,4 +1,4 @@ -package nextstep.app.ui; +package nextstep.security.exception; public class AuthenticationException extends RuntimeException { } diff --git a/src/main/java/nextstep/security/exception/SecurityExceptionHandler.java b/src/main/java/nextstep/security/exception/SecurityExceptionHandler.java new file mode 100644 index 00000000..618c189b --- /dev/null +++ b/src/main/java/nextstep/security/exception/SecurityExceptionHandler.java @@ -0,0 +1,15 @@ +package nextstep.security.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class SecurityExceptionHandler { + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException() { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } +} diff --git a/src/main/java/nextstep/security/filter/BasicAuthenticationFilter.java b/src/main/java/nextstep/security/filter/BasicAuthenticationFilter.java new file mode 100644 index 00000000..239b6e78 --- /dev/null +++ b/src/main/java/nextstep/security/filter/BasicAuthenticationFilter.java @@ -0,0 +1,67 @@ +package nextstep.security.filter; + +import nextstep.security.authentication.*; +import nextstep.security.domain.UserDetailsService; +import nextstep.security.exception.AuthenticationException; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Base64Utils; +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.List; + +public class BasicAuthenticationFilter extends OncePerRequestFilter { + private static final String AUTHENTICATION_SCHEME_BASIC = "Basic "; + + private final AuthenticationManager authenticationManager; + + public BasicAuthenticationFilter(UserDetailsService userDetailsService) { + this.authenticationManager = new ProviderManager( + List.of(new DaoAuthenticationProvider(userDetailsService)) + ); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + Authentication 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 Authentication convert(HttpServletRequest request) { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (!isValidAuthorizationHeader(authorizationHeader)) { + return null; + } + + String[] token = parseCredentials(authorizationHeader); + if (token.length != 2) { + throw new AuthenticationException(); + } + + return UsernamePasswordAuthenticationToken.unauthenticated(token[0], token[1]); + } + + private boolean isValidAuthorizationHeader(String authorizationHeader) { + return authorizationHeader != null && authorizationHeader.startsWith(AUTHENTICATION_SCHEME_BASIC); + } + + private String[] parseCredentials(String authorizationHeader) { + String base64Credentials = authorizationHeader.substring(AUTHENTICATION_SCHEME_BASIC.length()); + String credentials = new String(Base64Utils.decodeFromString(base64Credentials), StandardCharsets.UTF_8); + return credentials.split(":", 2); + } +} diff --git a/src/main/java/nextstep/security/filter/UsernamePasswordAuthenticationFilter.java b/src/main/java/nextstep/security/filter/UsernamePasswordAuthenticationFilter.java new file mode 100644 index 00000000..f963b1ac --- /dev/null +++ b/src/main/java/nextstep/security/filter/UsernamePasswordAuthenticationFilter.java @@ -0,0 +1,62 @@ +package nextstep.security.filter; + +import nextstep.security.authentication.*; +import nextstep.security.domain.UserDetailsService; +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; + +public class UsernamePasswordAuthenticationFilter extends GenericFilterBean { + public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; + private static final String DEFAULT_REQUEST_URI = "/login"; + + private final AuthenticationManager authenticationManager; + + public UsernamePasswordAuthenticationFilter(UserDetailsService userDetailsService) { + this.authenticationManager = new ProviderManager( + List.of(new DaoAuthenticationProvider(userDetailsService)) + ); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (!DEFAULT_REQUEST_URI.equals(((HttpServletRequest) request).getRequestURI())) { + chain.doFilter(request, response); + return; + } + + try { + Authentication authentication = convert(request); + if (authentication == null) { + chain.doFilter(request, response); + return; + } + + Authentication authenticate = authenticationManager.authenticate(authentication); + + HttpSession session = ((HttpServletRequest) request).getSession(); + session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, authenticate); + } catch (Exception e) { + ((HttpServletResponse) response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private Authentication convert(ServletRequest servletRequest) { + try { + HttpServletRequest request = (HttpServletRequest) servletRequest; + String username = request.getParameter("username"); + String password = request.getParameter("password"); + return UsernamePasswordAuthenticationToken.unauthenticated(username, password); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/nextstep/security/interceptor/BasicAuthenticationInterceptor.java b/src/main/java/nextstep/security/interceptor/BasicAuthenticationInterceptor.java new file mode 100644 index 00000000..bd50a428 --- /dev/null +++ b/src/main/java/nextstep/security/interceptor/BasicAuthenticationInterceptor.java @@ -0,0 +1,62 @@ +package nextstep.security.interceptor; + +import nextstep.security.context.SecurityContextHolder; +import nextstep.security.domain.UserDetails; +import nextstep.security.domain.UserDetailsService; +import nextstep.security.exception.AuthenticationException; +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; + +@Component +public class BasicAuthenticationInterceptor implements HandlerInterceptor { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BASIC_PREFIX = "Basic "; + + private final UserDetailsService userDetailsService; + + public BasicAuthenticationInterceptor(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); + + if (!isValidAuthorizationHeader(authorizationHeader)) { + return unauthorized(response); + } + + String[] credentials = parseCredentials(authorizationHeader); + if (credentials.length != 2) { + return unauthorized(response); + } + + UserDetails userDetails = userDetailsService.loadUserByUsernameAndPassword(credentials[0], credentials[1]); + if (userDetails.isEmpty()) { + throw new AuthenticationException(); + } + + SecurityContextHolder.setUserDetails(userDetails); + return true; + } + + private boolean isValidAuthorizationHeader(String authorizationHeader) { + return authorizationHeader != null && authorizationHeader.startsWith(BASIC_PREFIX); + } + + private String[] parseCredentials(String authorizationHeader) { + String base64Credentials = authorizationHeader.substring(BASIC_PREFIX.length()); + String credentials = new String(Base64Utils.decodeFromString(base64Credentials), StandardCharsets.UTF_8); + return credentials.split(":", 2); + } + + private boolean unauthorized(HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } +} diff --git a/src/main/java/nextstep/security/interceptor/FormLoginAuthenticationInterceptor.java b/src/main/java/nextstep/security/interceptor/FormLoginAuthenticationInterceptor.java new file mode 100644 index 00000000..5558ed97 --- /dev/null +++ b/src/main/java/nextstep/security/interceptor/FormLoginAuthenticationInterceptor.java @@ -0,0 +1,37 @@ +package nextstep.security.interceptor; + +import nextstep.security.context.SecurityContextHolder; +import nextstep.security.domain.UserDetails; +import nextstep.security.domain.UserDetailsService; +import nextstep.security.exception.AuthenticationException; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@Component +public class FormLoginAuthenticationInterceptor implements HandlerInterceptor { + private final UserDetailsService userDetailsService; + + public FormLoginAuthenticationInterceptor(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (SecurityContextHolder.getUserDetails() == null) { + String username = request.getParameter("username"); + String password = request.getParameter("password"); + + UserDetails userDetails = userDetailsService.loadUserByUsernameAndPassword(username, password); + if (userDetails.isEmpty()) { + throw new AuthenticationException(); + } + + SecurityContextHolder.setUserDetails(userDetails); + } + + return true; + } +}