diff --git a/README.md b/README.md index 1e7ba652..393378e3 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ # spring-security-authentication + + +아이디와 비밀번호를 기반으로 로그인 기능을 구현하고
+Basic 인증을 사용하여 사용자를 식별할 수 있도록 스프링 프레임워크를 사용하여 웹 앱으로 구현한다. + +- ``Spring security``의 내부 구조를 분석해 직접 구현 (단, ``Filter`` 대신 ``Interceptor`` 활용) + +--- +# 구현 요구 사항 + +1. 아이디와 비밀번호 기반 로그인 인증 구현 + + 로그인 요청 시 사용자가 입력한 아이디와 패스워드를 확인하여 인증한다. + + 로그인 성공 시 ``Session`` 을 사용하여 인증 정보를 저장한다. + + +2. Basic 인증 구현 + + 사용자 목록 조회 기능 (인증 진행 후 인가 진행) + - 인증 : Basic 인증을 사용하여 사용자를 식별한다. + + 이를 위해 요청의 **Authorization 헤더**에서 Basic 인증 정보를 추출 후 decode 하여 인증을 처리한다. + + 인증 성공 시 ``Session``을 사용하여 인증 정보를 저장한다. + + (다만, 인가를 위한 인증 및 권한 정보는 ThreadLocal 에 저장하여 활용) + - 인가 : ``Member``로 등록되어 있는, 인증된 사용자만 가능하도록 한다. + + 인증 ``Interceptor``가 통과되면 인가 ``Interceptor`` 진행 + + ThreadLocal 에서 조회하여 인증 정보가 있으면 인가 + + +3. 인터셉터 분리 + + ``HandlerInterceptor``를 사용하여 인증 관련 로직을 ``Controller``에서 분리한다. + + 앞서 구현한 두 인증 방식(아이디 비밀번호 로그인 방식과 Basic 인증 방식) 모두 인터셉터에서 처리되도록 구현한다. + + 가급적이면 하나의 인터셉터는 하나의 작업만 수행하도록 설계한다. + + 아이디/패스워드 기반 Authentication ``Interceptor`` + + Basic 인증 기반 Authentication ``Interceptor`` + + Authorization ``Interceptor`` + + + +4. 인증 로직과 서비스 로직 간의 패키지 분리 + + **서비스 코드와 인증 코드를 명확히 분리**하여 관리하도록 한다. + + 서비스 관련 코드는 ``app`` 패키지에 위치시키고, 인증 관련 코드는 ``security`` 패키지에 위치시킨다. + + 리팩터링 과정에서 패키지 간의 양방향 참조가 발생한다면 단방향 참조로 리팩터링한다. + + ``app`` 패키지는 ``security`` 패키지에 의존할 수 있지만, **``security`` 패키지는 ``app`` 패키지에 의존하지 않도록** 한다. + + 인증 관련 작업은 ``security`` 패키지에서 전담하도록 설계하여, 서비스 로직이 인증 세부 사항에 의존하지 않게 만든다. + + ``` + 패키지 간 의존성을 최소화하고, 변경에 강한 구조를 만드는 목적. + security 패키지를 독립적이고 재사용 가능하게 설계하려면, 직접적인 의존성을 피하기 위해 인터페이스를 구현하게 한다. (DIP) + ``` + + + + +
+ +--- +# API 정의 +### 로그인 + - /login [POST] 아이디와 비밀번호를 확인하여 인증. (인증 후 Session에 인증 정보 저장) + +### 사용자 조회 + - /member [GET] 사용자 목록 조회. (단, 인증 성공 후 인증 정보가 있을 경우만 인가) + diff --git a/build.gradle b/build.gradle index 99766160..b1e5d784 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,17 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' +// implementation 'org.springframework.boot:spring-boot-starter-security' +// implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +// runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' +// testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + //테스트에서 lombok 사용 + testCompileOnly '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/app/SecurityAuthenticationApplication.java index 0f8eb47d..9a8b666d 100644 --- a/src/main/java/nextstep/app/SecurityAuthenticationApplication.java +++ b/src/main/java/nextstep/app/SecurityAuthenticationApplication.java @@ -2,12 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; @SpringBootApplication +@ComponentScan(basePackages = {"nextstep.app", "nextstep.util"}) public class SecurityAuthenticationApplication { - public static void main(String[] args) { SpringApplication.run(SecurityAuthenticationApplication.class, args); } - } diff --git a/src/main/java/nextstep/app/WebConfig.java b/src/main/java/nextstep/app/WebConfig.java new file mode 100644 index 00000000..6d36b20e --- /dev/null +++ b/src/main/java/nextstep/app/WebConfig.java @@ -0,0 +1,19 @@ +package nextstep.app; + +import nextstep.security.web.authentication.BasicAuthenticationInterceptor; +import nextstep.security.web.authentication.UsernamePasswordAuthenticationInterceptor; +import nextstep.security.web.authorization.AuthorizationInterceptor; +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 { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new UsernamePasswordAuthenticationInterceptor()).addPathPatterns("/login"); + // /members 경로는 인증 후 인가 처리 + registry.addInterceptor(new BasicAuthenticationInterceptor()).addPathPatterns("/members"); + registry.addInterceptor(new AuthorizationInterceptor()).addPathPatterns("/members"); + } +} diff --git a/src/main/java/nextstep/app/service/MemberService.java b/src/main/java/nextstep/app/service/MemberService.java new file mode 100644 index 00000000..445dfd70 --- /dev/null +++ b/src/main/java/nextstep/app/service/MemberService.java @@ -0,0 +1,29 @@ +package nextstep.app.service; + +import lombok.RequiredArgsConstructor; +import nextstep.app.domain.Member; +import nextstep.app.domain.MemberRepository; +import nextstep.security.core.userdetails.User; +import nextstep.security.core.userdetails.UserDetails; +import nextstep.security.core.userdetails.UserDetailsService; +import nextstep.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +public class MemberService implements UserDetailsService { + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException(username)); + + return User.builder() + .username(member.getEmail()) + .password(member.getPassword()) + .roles("MEMBER") + .build(); + } +} diff --git a/src/main/java/nextstep/app/ui/LoginController.java b/src/main/java/nextstep/app/ui/LoginController.java index 0ea94f1b..64a73f2a 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 lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -10,23 +10,13 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; +@Slf4j @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) { + log.info("Login request received"); 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..299b140c 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -1,5 +1,6 @@ package nextstep.app.ui; +import lombok.RequiredArgsConstructor; import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; import org.springframework.http.ResponseEntity; @@ -9,14 +10,11 @@ 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(); diff --git a/src/main/java/nextstep/security/authentication/AuthenticationCredentialsNotFoundException.java b/src/main/java/nextstep/security/authentication/AuthenticationCredentialsNotFoundException.java new file mode 100644 index 00000000..0ea0aaeb --- /dev/null +++ b/src/main/java/nextstep/security/authentication/AuthenticationCredentialsNotFoundException.java @@ -0,0 +1,9 @@ +package nextstep.security.authentication; + +import nextstep.security.core.AuthenticationException; + +public class AuthenticationCredentialsNotFoundException extends AuthenticationException { + public AuthenticationCredentialsNotFoundException(String msg) { + super(msg); + } +} 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..604b166e --- /dev/null +++ b/src/main/java/nextstep/security/authentication/AuthenticationManager.java @@ -0,0 +1,12 @@ +package nextstep.security.authentication; + +import nextstep.security.core.Authentication; +import nextstep.security.core.AuthenticationException; + +public class AuthenticationManager { + private final AuthenticationProvider authenticationProvider = new AuthenticationProvider(); + + public Authentication authenticate(Authentication authentication) throws AuthenticationException{ + return authenticationProvider.authenticate(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..03b0eac5 --- /dev/null +++ b/src/main/java/nextstep/security/authentication/AuthenticationProvider.java @@ -0,0 +1,46 @@ +package nextstep.security.authentication; + +import nextstep.util.ApplicationContextProvider; +import nextstep.security.core.Authentication; +import nextstep.security.core.AuthenticationException; +import nextstep.security.core.userdetails.UserDetails; +import nextstep.security.core.userdetails.UserDetailsService; + +import java.util.Objects; + +public class AuthenticationProvider { + + private final UserDetailsService userDetailsService; + + public AuthenticationProvider() { + this.userDetailsService = ApplicationContextProvider.getApplicationContext().getBean(UserDetailsService.class); + } + + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + // 사용자 정보 load + UserDetails loadedUser = retrieveUser(authentication.getPrincipal()); + + // 패스워드 체크 + authenticationChecks(authentication, loadedUser); + + // 인증 정보 생성 + return createSuccessAuthentication(loadedUser); + } + + + private UserDetails retrieveUser(Object username) { + return userDetailsService.loadUserByUsername((String) username); + } + + private void authenticationChecks(Authentication authentication, UserDetails loadedUser) { + if (!Objects.equals(authentication.getCredentials().toString(), loadedUser.getPassword())) { + throw new BadCredentialsException("Bad credentials"); + } + } + + private Authentication createSuccessAuthentication(UserDetails user) { + Authentication authentication = new Authentication(user, null, user.getAuthorities()); + authentication.setAuthenticated(true); + return authentication; + } +} diff --git a/src/main/java/nextstep/security/authentication/BadCredentialsException.java b/src/main/java/nextstep/security/authentication/BadCredentialsException.java new file mode 100644 index 00000000..be46a7a0 --- /dev/null +++ b/src/main/java/nextstep/security/authentication/BadCredentialsException.java @@ -0,0 +1,13 @@ +package nextstep.security.authentication; + +import nextstep.security.core.AuthenticationException; + +public class BadCredentialsException extends AuthenticationException { + public BadCredentialsException(String msg) { + super(msg); + } + + public BadCredentialsException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/src/main/java/nextstep/security/authorization/AccessDeniedException.java b/src/main/java/nextstep/security/authorization/AccessDeniedException.java new file mode 100644 index 00000000..9b48f514 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AccessDeniedException.java @@ -0,0 +1,12 @@ +package nextstep.security.authorization; + +/** + * Thrown if an Authentication object does not hold a required authority. + */ +public class AccessDeniedException extends RuntimeException { + + public AccessDeniedException(String msg) { + super(msg); + } + +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationDecision.java b/src/main/java/nextstep/security/authorization/AuthorizationDecision.java new file mode 100644 index 00000000..f91966e8 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationDecision.java @@ -0,0 +1,10 @@ +package nextstep.security.authorization; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AuthorizationDecision { + private final boolean granted; +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationManager.java b/src/main/java/nextstep/security/authorization/AuthorizationManager.java new file mode 100644 index 00000000..094be54b --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationManager.java @@ -0,0 +1,17 @@ +package nextstep.security.authorization; + +import nextstep.security.core.Authentication; + +import javax.servlet.http.HttpServletRequest; +import java.util.function.Supplier; + +public class AuthorizationManager { + public AuthorizationDecision check(Supplier authentication, HttpServletRequest request) { + boolean granted = isGranted(authentication.get()); + return new AuthorizationDecision(granted); + } + + private boolean isGranted(Authentication authentication) { + return authentication != null && authentication.isAuthenticated(); + } +} 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..8d63e6d4 --- /dev/null +++ b/src/main/java/nextstep/security/context/SecurityContext.java @@ -0,0 +1,16 @@ +package nextstep.security.context; + + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import nextstep.security.core.Authentication; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +public class SecurityContext { + private Authentication authentication; +} 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..2e163b23 --- /dev/null +++ b/src/main/java/nextstep/security/context/SecurityContextHolder.java @@ -0,0 +1,34 @@ +package nextstep.security.context; + +import org.springframework.util.Assert; + +import java.util.function.Supplier; + +public class SecurityContextHolder { + private static final ThreadLocal> contextHolder = new ThreadLocal<>(); + + public static SecurityContext getContext() { + Supplier result = contextHolder.get(); + if (result == null) { + SecurityContext context = createEmptyContext(); + result = () -> context; + contextHolder.set(result); + } + + return result.get(); + } + + public static void setContext(SecurityContext context) { + Assert.notNull(context, "Only non-null SecurityContext instances are permitted"); + contextHolder.set(() -> context); //map.set(this, value); + } + + public static void clearContext() { + contextHolder.remove(); + } + + private static SecurityContext createEmptyContext() { + return new SecurityContext(); + } +} + diff --git a/src/main/java/nextstep/security/core/Authentication.java b/src/main/java/nextstep/security/core/Authentication.java new file mode 100644 index 00000000..e3d889f1 --- /dev/null +++ b/src/main/java/nextstep/security/core/Authentication.java @@ -0,0 +1,33 @@ +package nextstep.security.core; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import nextstep.security.core.authority.GrantedAuthority; +import nextstep.security.core.userdetails.UserDetails; + +import java.util.Collection; + + +@Getter +@Setter +@AllArgsConstructor +public class Authentication { + private final Object principal; + private Object credentials; + private final Collection authorities; + private boolean authenticated = false; + + public Authentication(Object principal, Object credentials, Collection authorities) { + this.principal = principal; + this.credentials = credentials; + this.authorities = authorities; + } + + public String getName(){ + if (principal instanceof UserDetails){ + return ((UserDetails)principal).getUsername(); + } + return principal.toString(); + } +} diff --git a/src/main/java/nextstep/security/core/AuthenticationException.java b/src/main/java/nextstep/security/core/AuthenticationException.java new file mode 100644 index 00000000..b63b8b3d --- /dev/null +++ b/src/main/java/nextstep/security/core/AuthenticationException.java @@ -0,0 +1,11 @@ +package nextstep.security.core; + +public class AuthenticationException extends RuntimeException { + public AuthenticationException(String msg) { + super(msg); + } + public AuthenticationException(String msg, Throwable cause) { + super(msg, cause); + } + +} diff --git a/src/main/java/nextstep/security/core/authority/GrantedAuthority.java b/src/main/java/nextstep/security/core/authority/GrantedAuthority.java new file mode 100644 index 00000000..3469ae14 --- /dev/null +++ b/src/main/java/nextstep/security/core/authority/GrantedAuthority.java @@ -0,0 +1,12 @@ +package nextstep.security.core.authority; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@AllArgsConstructor +@Getter +@Setter +public class GrantedAuthority { + private final String role; +} diff --git a/src/main/java/nextstep/security/core/userdetails/User.java b/src/main/java/nextstep/security/core/userdetails/User.java new file mode 100644 index 00000000..bce321e2 --- /dev/null +++ b/src/main/java/nextstep/security/core/userdetails/User.java @@ -0,0 +1,32 @@ +package nextstep.security.core.userdetails; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import nextstep.security.core.authority.GrantedAuthority; +import org.springframework.util.Assert; + +import java.util.HashSet; +import java.util.Set; + +@Builder +@Getter +@Setter +public class User implements UserDetails { + private String password; + private final String username; + private final Set authorities; + + + public static class UserBuilder { + public UserBuilder roles(String... roles) { + this.authorities = new HashSet<>(); + for (String role : roles) { + Assert.isTrue(!role.startsWith("ROLE_"), + () -> role + " cannot start with ROLE_ (it is automatically added)"); + authorities.add(new GrantedAuthority("ROLE_" + role)); + } + return this; + } + } +} diff --git a/src/main/java/nextstep/security/core/userdetails/UserDetails.java b/src/main/java/nextstep/security/core/userdetails/UserDetails.java new file mode 100644 index 00000000..dfe9c294 --- /dev/null +++ b/src/main/java/nextstep/security/core/userdetails/UserDetails.java @@ -0,0 +1,12 @@ +package nextstep.security.core.userdetails; + +import nextstep.security.core.authority.GrantedAuthority; + +import java.util.Collection; + +public interface UserDetails { + + String getUsername(); + String getPassword(); + Collection getAuthorities(); +} diff --git a/src/main/java/nextstep/security/core/userdetails/UserDetailsService.java b/src/main/java/nextstep/security/core/userdetails/UserDetailsService.java new file mode 100644 index 00000000..b1f7923f --- /dev/null +++ b/src/main/java/nextstep/security/core/userdetails/UserDetailsService.java @@ -0,0 +1,5 @@ +package nextstep.security.core.userdetails; + +public interface UserDetailsService { + UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; +} diff --git a/src/main/java/nextstep/security/core/userdetails/UsernameNotFoundException.java b/src/main/java/nextstep/security/core/userdetails/UsernameNotFoundException.java new file mode 100644 index 00000000..130f730c --- /dev/null +++ b/src/main/java/nextstep/security/core/userdetails/UsernameNotFoundException.java @@ -0,0 +1,12 @@ +package nextstep.security.core.userdetails; + +import nextstep.security.core.AuthenticationException; + +public class UsernameNotFoundException extends AuthenticationException { + public UsernameNotFoundException(String msg) { + super(msg); + } + public UsernameNotFoundException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/src/main/java/nextstep/security/web/authentication/AuthenticationInterceptor.java b/src/main/java/nextstep/security/web/authentication/AuthenticationInterceptor.java new file mode 100644 index 00000000..8bb123a1 --- /dev/null +++ b/src/main/java/nextstep/security/web/authentication/AuthenticationInterceptor.java @@ -0,0 +1,47 @@ +package nextstep.security.web.authentication; + +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.context.SecurityContext; +import nextstep.security.context.SecurityContextHolder; +import nextstep.security.core.Authentication; +import nextstep.security.core.AuthenticationException; +import nextstep.security.core.userdetails.UserDetails; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public abstract class AuthenticationInterceptor implements HandlerInterceptor { + public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT"; + private final AuthenticationManager authenticationManager = new AuthenticationManager(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + Authentication authRequest = (Authentication) request.getAttribute("authRequest"); + Authentication authResponse; + + try { + // 인증 + authResponse = authenticationManager.authenticate(authRequest); + + //인증 정보 저장 + SecurityContextHolder.setContext(new SecurityContext(authResponse)); + + } catch (AuthenticationException e) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + return false; + } + + // 인증 정보를 세션에 저장 + UserDetails principal = (UserDetails) authResponse.getPrincipal(); + request.getSession().setAttribute(SPRING_SECURITY_CONTEXT_KEY, principal); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + // SecurityContext 초기화 + SecurityContextHolder.clearContext(); + } +} diff --git a/src/main/java/nextstep/security/web/authentication/BasicAuthenticationInterceptor.java b/src/main/java/nextstep/security/web/authentication/BasicAuthenticationInterceptor.java new file mode 100644 index 00000000..3d1ca83e --- /dev/null +++ b/src/main/java/nextstep/security/web/authentication/BasicAuthenticationInterceptor.java @@ -0,0 +1,77 @@ +package nextstep.security.web.authentication; + +import nextstep.security.authentication.BadCredentialsException; +import nextstep.security.context.SecurityContextHolder; +import nextstep.security.core.Authentication; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class BasicAuthenticationInterceptor extends AuthenticationInterceptor { + + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + Authentication authRequest; + // 인증 정보 추출 + try { + authRequest = convert(request); + } catch (BadCredentialsException e) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + return false; + } + + // 인증 정보가 없을 경우 로그인 페이지로 리다이렉트 + if (authRequest == null) { + response.sendRedirect(request.getContextPath() + "/login"); + return false; + } + + //인증이 필요할 경우 인증 + if (authenticationIsRequired((String) authRequest.getPrincipal())) { + request.setAttribute("authRequest", authRequest); + return super.preHandle(request, response, handler); + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + super.afterCompletion(request, response, handler, ex); + } + + private Authentication convert(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + + //정상 요청 여부 확인 + if (header == null) return null; + header = header.trim(); + if (!StringUtils.startsWithIgnoreCase(header, "Basic")) return null; + if (header.equalsIgnoreCase("Basic")) + throw new BadCredentialsException("Empty basic authentication token"); + + //BASE64 decoding and parsing + byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8); + byte[] decoded = Base64.getDecoder().decode(base64Token); + String token = new String(decoded, StandardCharsets.UTF_8); + int delim = token.indexOf(":"); + if (delim == -1) { + throw new BadCredentialsException("Invalid basic authentication token"); + } + + return new Authentication(token.substring(0, delim), token.substring(delim + 1), null); + } + + private boolean authenticationIsRequired(String username) { + Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication(); + return existingAuth == null || !existingAuth.getName().equals(username) || !existingAuth.isAuthenticated(); //모두 불만족하면 true + } + +} +//1. Basic 인증을 사용하여 사용자를 식별한다. 요청의 Authorization 헤더에서 Basic 인증 정보를 추출 +//2. 인증 성공 시 Session 을 사용하여 인증 정보를 저장한다. +//3. 인가 필터로 -> Member 로 등록되어있는 사용자만 가능하도록 한다 \ No newline at end of file diff --git a/src/main/java/nextstep/security/web/authentication/UsernamePasswordAuthenticationInterceptor.java b/src/main/java/nextstep/security/web/authentication/UsernamePasswordAuthenticationInterceptor.java new file mode 100644 index 00000000..9b8dc58a --- /dev/null +++ b/src/main/java/nextstep/security/web/authentication/UsernamePasswordAuthenticationInterceptor.java @@ -0,0 +1,27 @@ +package nextstep.security.web.authentication; + +import lombok.extern.slf4j.Slf4j; +import nextstep.security.core.Authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + + +@Slf4j +public class UsernamePasswordAuthenticationInterceptor extends AuthenticationInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // request parameter 로 전달된 정보를 인증 정보로 변환 + Authentication authRequest = new Authentication(request.getParameter("username"), request.getParameter("password"), null); + + // 인증 + request.setAttribute("authRequest", authRequest); + return super.preHandle(request, response, handler); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + super.afterCompletion(request, response, handler, ex); + } +} \ No newline at end of file diff --git a/src/main/java/nextstep/security/web/authorization/AuthorizationInterceptor.java b/src/main/java/nextstep/security/web/authorization/AuthorizationInterceptor.java new file mode 100644 index 00000000..82080891 --- /dev/null +++ b/src/main/java/nextstep/security/web/authorization/AuthorizationInterceptor.java @@ -0,0 +1,45 @@ +package nextstep.security.web.authorization; + +import nextstep.security.authentication.AuthenticationCredentialsNotFoundException; +import nextstep.security.authorization.AccessDeniedException; +import nextstep.security.authorization.AuthorizationDecision; +import nextstep.security.authorization.AuthorizationManager; +import nextstep.security.context.SecurityContextHolder; +import nextstep.security.core.Authentication; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class AuthorizationInterceptor implements HandlerInterceptor { + + private final AuthorizationManager authorizationManager = new AuthorizationManager(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + try { + // 인가 : AuthorizationManger 에게 Authentication 전달하여 권한 확인 + AuthorizationDecision decision = authorizationManager.check(this::getAuthentication, request); + + // 인가 권한이 안맞다면 예외 발생 + if (decision != null && !decision.isGranted()) { + throw new AccessDeniedException("Unauthorized"); + } + + } catch (AuthenticationCredentialsNotFoundException | AccessDeniedException e) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + return false; + } + + return true; + } + + private Authentication getAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new AuthenticationCredentialsNotFoundException("Unauthenticated"); + } + return authentication; + } +} diff --git a/src/main/java/nextstep/util/ApplicationContextProvider.java b/src/main/java/nextstep/util/ApplicationContextProvider.java new file mode 100644 index 00000000..299aa72d --- /dev/null +++ b/src/main/java/nextstep/util/ApplicationContextProvider.java @@ -0,0 +1,21 @@ +package nextstep.util; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +public class ApplicationContextProvider implements ApplicationContextAware { + + private static ApplicationContext context; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + context = applicationContext; + } + + public static ApplicationContext getApplicationContext() { + return context; + } +} diff --git a/src/test/java/nextstep/app/LoginTest.java b/src/test/java/nextstep/app/LoginTest.java index 717bcc8a..e83007d6 100644 --- a/src/test/java/nextstep/app/LoginTest.java +++ b/src/test/java/nextstep/app/LoginTest.java @@ -1,5 +1,6 @@ package nextstep.app; +import lombok.extern.slf4j.Slf4j; import nextstep.app.domain.Member; import nextstep.app.infrastructure.InmemoryMemberRepository; import org.junit.jupiter.api.DisplayName; @@ -9,7 +10,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; @@ -18,6 +18,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@Slf4j @SpringBootTest @AutoConfigureMockMvc class LoginTest { diff --git a/src/test/java/nextstep/app/MemberTest.java b/src/test/java/nextstep/app/MemberTest.java index 58aba17b..5df7ef6b 100644 --- a/src/test/java/nextstep/app/MemberTest.java +++ b/src/test/java/nextstep/app/MemberTest.java @@ -42,7 +42,7 @@ void members() throws Exception { String encoded = Base64Utils.encodeToString(token.getBytes()); ResultActions loginResponse = mockMvc.perform(get("/members") - .header("Authorization", "Basic " + encoded) + .header("Authorization", "Basic " + encoded) //Basic YUBhLmNvbTpwYXNzd29yZA== .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) );