diff --git a/README.md b/README.md index 1e7ba652..ebbc8fdc 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ # spring-security-authentication + +## 기능 요구 사항 +- 아이디와 비밀번호를 기반으로 로그인 기능 구현 + - Basic 인증 사용 + - 스프링 프레임워크를 사용해 웹 앱으로 구현 + +### 아이디와 비밀번호 기반 로그인 구현 +- POST /login 경로로 로그인 요청 +- 사용자가 입력한 아이디와 비밀번호를 확인하여 인증 +- 로그인 성공 시 Session을 사용하여 인증 정보 저장 +- LoginTest 통과 + + +### Basic 인증 구현 +- GET /member 요청 시 사용자 목록 조회 +- 단, Member로 등록되어 있는 사용자만 가능하도록 한다. +- 이를 위해 Basic 인증을 사용하여 사용자 식별 +- 요청의 Authorization 헤더에서 Basic 인증 정보를 추출하여 인증 처리 +- 인증 성공 시 Session을 사용하여 인증 정보 저장 +- MemberTest 통과 + +### 인터셉터 분리 +- HandlerInterceptor를 사용하여 인증 관련 로직을 Controller 클래스에서 분리 + - 앞서 구현한 두 인증 방식 모두 인터셉터에서 처리되도록 구현 + - 가급적이면 하나의 인터셉터는 하나의 작업만 수행하도록 한다. + +### 인증 로직과 서비스 로직 간의 패키지 분리 +- 서비스 코드와 인증 코드를 명확히 분리하여 관리 + - 서비스 관련 코드는 app 패키지에 위치시키고, 인증 관련 코드는 security 패키지에 위치 +- 리팩터링 과정에서 패키지 간의 양방향 참조가 발생한다면 단방향 참조로 리팩터링 + - app 패키지는 security 패키지에 의존할 수 있지만, + - security 패키지는 app 패키지에 의존하지 않도록 한다. +- 인증 관련 작업은 security 패키지에서 전담하도록 설계하여, 서비스 로직이 인증 세부 사항에 의존하지 않게 만든다. +- LoginTest, MemberTest 의 모든 테스트는 지속해서 통과하여야 한다. diff --git a/build.gradle b/build.gradle index 99766160..95741fee 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'org.springframework.boot' version '2.7.1' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' + id 'org.jetbrains.kotlin.jvm' } group = 'nextstep' @@ -15,8 +16,19 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } tasks.named('test') { useJUnitPlatform() } +compileKotlin { + kotlinOptions { + jvmTarget = "11" + } +} +compileTestKotlin { + kotlinOptions { + jvmTarget = "11" + } +} diff --git a/settings.gradle b/settings.gradle index b187a12b..20162923 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,6 @@ +pluginManagement { + plugins { + id 'org.jetbrains.kotlin.jvm' version '1.9.21' + } +} rootProject.name = 'spring-security-authentication' 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..f7e090bf --- /dev/null +++ b/src/main/java/nextstep/app/config/WebConfig.java @@ -0,0 +1,23 @@ +package nextstep.app.config; + +import nextstep.security.interceptor.BasicAuthenticationInterceptor; +import nextstep.security.interceptor.FormLoginAuthorizationInterceptor; +import nextstep.security.service.UserDetailsService; +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 UserDetailsService userDetailsService; + + public WebConfig(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new FormLoginAuthorizationInterceptor(userDetailsService)).addPathPatterns("/login"); + registry.addInterceptor(new BasicAuthenticationInterceptor(userDetailsService)).addPathPatterns("/members"); + } +} diff --git a/src/main/java/nextstep/app/domain/Member.java b/src/main/java/nextstep/app/domain/member/param/Member.java similarity index 78% rename from src/main/java/nextstep/app/domain/Member.java rename to src/main/java/nextstep/app/domain/member/param/Member.java index 6cafa9c7..bafa7031 100644 --- a/src/main/java/nextstep/app/domain/Member.java +++ b/src/main/java/nextstep/app/domain/member/param/Member.java @@ -1,6 +1,8 @@ -package nextstep.app.domain; +package nextstep.app.domain.member.param; -public class Member { +import nextstep.security.param.UserDetails; + +public class Member implements UserDetails { private final String email; private final String password; private final String name; @@ -13,10 +15,12 @@ public Member(String email, String password, String name, String imageUrl) { this.imageUrl = imageUrl; } + @Override public String getEmail() { return email; } + @Override public String getPassword() { return password; } diff --git a/src/main/java/nextstep/app/domain/MemberRepository.java b/src/main/java/nextstep/app/domain/member/repository/MemberRepository.java similarity index 67% rename from src/main/java/nextstep/app/domain/MemberRepository.java rename to src/main/java/nextstep/app/domain/member/repository/MemberRepository.java index 2eb5cdbb..feee5e96 100644 --- a/src/main/java/nextstep/app/domain/MemberRepository.java +++ b/src/main/java/nextstep/app/domain/member/repository/MemberRepository.java @@ -1,4 +1,6 @@ -package nextstep.app.domain; +package nextstep.app.domain.member.repository; + +import nextstep.app.domain.member.param.Member; import java.util.List; import java.util.Optional; diff --git a/src/main/java/nextstep/app/domain/member/service/MemberService.java b/src/main/java/nextstep/app/domain/member/service/MemberService.java new file mode 100644 index 00000000..2d4e3f61 --- /dev/null +++ b/src/main/java/nextstep/app/domain/member/service/MemberService.java @@ -0,0 +1,23 @@ +package nextstep.app.domain.member.service; + +import nextstep.app.domain.member.param.Member; +import nextstep.app.domain.member.repository.MemberRepository; +import nextstep.security.service.UserDetailsService; +import org.springframework.stereotype.Service; + +@Service +public class MemberService implements UserDetailsService { + + private final MemberRepository memberRepository; + + public MemberService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Override + public Member retrieveMemberByEmailAndPassword(String email, String password) { + return memberRepository.findByEmail(email) + .filter(member -> member.getPassword().equals(password)) + .orElse(null); + } +} diff --git a/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java b/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java index 5a6062cf..fba17bc6 100644 --- a/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java +++ b/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java @@ -1,7 +1,7 @@ package nextstep.app.infrastructure; -import nextstep.app.domain.Member; -import nextstep.app.domain.MemberRepository; +import nextstep.app.domain.member.param.Member; +import nextstep.app.domain.member.repository.MemberRepository; import org.springframework.stereotype.Repository; import java.util.HashMap; diff --git a/src/main/java/nextstep/app/ui/LoginController.java b/src/main/java/nextstep/app/ui/LoginController.java index 0ea94f1b..f630ac78 100644 --- a/src/main/java/nextstep/app/ui/LoginController.java +++ b/src/main/java/nextstep/app/ui/LoginController.java @@ -1,6 +1,6 @@ package nextstep.app.ui; -import nextstep.app.domain.MemberRepository; +import nextstep.app.domain.member.repository.MemberRepository; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; diff --git a/src/main/java/nextstep/app/ui/MemberController.java b/src/main/java/nextstep/app/ui/MemberController.java index c8cc74d6..fe45335f 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -1,7 +1,7 @@ package nextstep.app.ui; -import nextstep.app.domain.Member; -import nextstep.app.domain.MemberRepository; +import nextstep.app.domain.member.param.Member; +import nextstep.app.domain.member.repository.MemberRepository; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; 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..58d7c342 --- /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 BASIC_AUTH_HEADER = "Basic"; + public static final String USERNAME = "username"; + public static final String PASSWORD = "password"; + public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; + +} 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..d67a24ba --- /dev/null +++ b/src/main/java/nextstep/security/interceptor/BasicAuthenticationInterceptor.java @@ -0,0 +1,54 @@ +package nextstep.security.interceptor; + +import nextstep.security.param.UserDetails; +import nextstep.security.service.UserDetailsService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static nextstep.security.constants.SecurityConstants.BASIC_AUTH_HEADER; + +public class BasicAuthenticationInterceptor implements HandlerInterceptor { + private final UserDetailsService userDetailService; + + public BasicAuthenticationInterceptor(UserDetailsService userDetailService) { + this.userDetailService = userDetailService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorizationHeader == null || !authorizationHeader.startsWith(BASIC_AUTH_HEADER)) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증되지 않은 사용자입니다."); + return false; + } + + String[] values = getBasicValues(authorizationHeader); + if (values.length != 2) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증되지 않은 사용자입니다."); + return false; + } + + String username = values[0]; + String password = values[1]; + + UserDetails userDetails = userDetailService.retrieveMemberByEmailAndPassword(username, password); + if (userDetails == null) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return false; + } + + return true; + } + + private String[] getBasicValues(String authorizationHeader) { + String base64Credentials = authorizationHeader.substring(BASIC_AUTH_HEADER.length()).trim(); + String credentials = new String(Base64.getDecoder().decode(base64Credentials), StandardCharsets.UTF_8); + return credentials.split(":", 2); + } +} diff --git a/src/main/java/nextstep/security/interceptor/FormLoginAuthorizationInterceptor.java b/src/main/java/nextstep/security/interceptor/FormLoginAuthorizationInterceptor.java new file mode 100644 index 00000000..f8e41e11 --- /dev/null +++ b/src/main/java/nextstep/security/interceptor/FormLoginAuthorizationInterceptor.java @@ -0,0 +1,38 @@ +package nextstep.security.interceptor; + +import nextstep.security.param.UserDetails; +import nextstep.security.service.UserDetailsService; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static nextstep.security.constants.SecurityConstants.*; + +public class FormLoginAuthorizationInterceptor implements HandlerInterceptor { + private final UserDetailsService userDetailService; + + public FormLoginAuthorizationInterceptor(UserDetailsService userDetailService) { + this.userDetailService = userDetailService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String username = request.getParameter(USERNAME); + String password = request.getParameter(PASSWORD); + + if (username == null || password == null) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "username, password가 필요합니다."); + return false; + } + + UserDetails userDetails = userDetailService.retrieveMemberByEmailAndPassword(username, password); + if (userDetails == null) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "username, password가 일치하지 않슴니다."); + return false; + } + + request.getSession().setAttribute(SPRING_SECURITY_CONTEXT_KEY, userDetails); + return true; + } +} diff --git a/src/main/java/nextstep/security/param/UserDetails.java b/src/main/java/nextstep/security/param/UserDetails.java new file mode 100644 index 00000000..75068914 --- /dev/null +++ b/src/main/java/nextstep/security/param/UserDetails.java @@ -0,0 +1,7 @@ +package nextstep.security.param; + +public interface UserDetails { + String getEmail(); + + String getPassword(); +} 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 00000000..0c7dd4e1 --- /dev/null +++ b/src/main/java/nextstep/security/service/UserDetailsService.java @@ -0,0 +1,7 @@ +package nextstep.security.service; + +import nextstep.security.param.UserDetails; + +public interface UserDetailsService { + UserDetails retrieveMemberByEmailAndPassword(String email, String password); +} diff --git a/src/test/java/nextstep/app/LoginTest.java b/src/test/java/nextstep/app/LoginTest.java index 717bcc8a..149cb555 100644 --- a/src/test/java/nextstep/app/LoginTest.java +++ b/src/test/java/nextstep/app/LoginTest.java @@ -1,6 +1,6 @@ package nextstep.app; -import nextstep.app.domain.Member; +import nextstep.app.domain.member.param.Member; import nextstep.app.infrastructure.InmemoryMemberRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,7 +9,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; 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; diff --git a/src/test/java/nextstep/app/MemberTest.java b/src/test/java/nextstep/app/MemberTest.java index 58aba17b..254f31b9 100644 --- a/src/test/java/nextstep/app/MemberTest.java +++ b/src/test/java/nextstep/app/MemberTest.java @@ -1,7 +1,7 @@ package nextstep.app; -import nextstep.app.domain.Member; -import nextstep.app.domain.MemberRepository; +import nextstep.app.domain.member.param.Member; +import nextstep.app.domain.member.repository.MemberRepository; import nextstep.app.infrastructure.InmemoryMemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName;