Skip to content

[인증(Authentication) - 2, 3, 4단계] 김선호 미션 제출합니다. 🚀 #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
200fb25
refactor(feedback): API URI 마다 SecurityFilterChain을 생성하지 않도록 Security…
haero77 Feb 9, 2025
dfbd707
feat(step2): 인증 정보(사용자명, 인증 상태 등)를 담는 Authentication 정의 & 구현체 Usernam…
haero77 Feb 10, 2025
470703c
feat: 인증 예외를 의미하는 AuthenticationException 정의
haero77 Feb 11, 2025
2975d27
feat(step2): 구현체 타입에 따라 인증된 Authentication을 리턴하는 AuthenticationProvid…
haero77 Feb 11, 2025
523128f
feat(step2): UsernamePasswordAuthenticationToken의 인증을 지원하는 DaoAuthent…
haero77 Feb 11, 2025
13bf0b7
feat(step2): 인증 요청 받은 authentication에 대한 인증을 시도하는 AuthenticationManag…
haero77 Feb 11, 2025
6c7ae54
feat(step2): 주입 받은 AuthenticationProvider를 순회하며 인증을 시도하는 Authenticati…
haero77 Feb 11, 2025
f708698
refactor(step2): 폼 로그인 인증 처리를 AuthenticationManager에 위임하도록 변경
haero77 Feb 11, 2025
951231b
refactor(step2): 폼 로그인 인증을 처리하는 필터의 이름을 UsernamePasswordAuthenticatio…
haero77 Feb 11, 2025
57bc51c
refactor(step2): Basic 인증 처리를 AuthenticationManager에 위임하도록 변경
haero77 Feb 11, 2025
4244c33
refactor(feedback): 인증 필터가 요청당 한 번만 수행되는 것을 보장하도록 OncePerRequestFilte…
haero77 Feb 12, 2025
3122529
feat(step3): Authentication을 보관하는 SecurityContext 정의, 구현체 SecurityCon…
haero77 Feb 12, 2025
02080d2
feat(step3): 각 스레드별로 독립된 SecurityContext를 관리하는 SecurityContextHolder 구현
haero77 Feb 12, 2025
b8b7894
refactor(step3): BasicAuthenticationFilter에서 인증된 Authentication을 Secu…
haero77 Feb 12, 2025
ff9812e
refactor: 폼 로그인 인증 - username과 password를 추출하여 인증하는 로직을 메서드로 분리
haero77 Feb 12, 2025
4de355e
refactor(step3): HTTP Session을 사용하지 않고 SecurityContextHolder를 통해 인증 정…
haero77 Feb 12, 2025
302079c
feat(step4): 사용자의 인증 요청 이후 사용자의 인증을 유지하기 위한 SecurityContextRepository 정의
haero77 Feb 13, 2025
424470a
feat(step4): 인증을 HTTP Session에 저장하기 위한 HttpSessionSecurityContextRepo…
haero77 Feb 13, 2025
4ae90bc
feat(step4): SecurityContextRepository를 사용하여 SecurityContext를 얻고, 이것을…
haero77 Feb 13, 2025
e2bd956
refactor(step4): 인증 필터에서 인증 후 securityContextRepository에 SecurityCont…
haero77 Feb 13, 2025
ec51df0
test(step4): HTTP Session에 인증 정보 저장 후 인증 유지 테스트 추가
haero77 Feb 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions src/main/java/nextstep/app/security/AppSecurityConfiguration.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package nextstep.app.security;

import nextstep.security.context.HttpSessionSecurityContextRepository;
import nextstep.security.context.SecurityContextHolderFilter;
import nextstep.security.context.SecurityContextRepository;
import nextstep.security.filter.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -17,20 +20,13 @@ public AppSecurityConfiguration(UserDetailsService userDetailsService) {

@Bean
public SecurityFilterChain formLoginSecurityFilterChain() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피드백 반영 👍

return new DefaultSecurityFilterChain(
(httpServletRequest) -> httpServletRequest.getRequestURI().equals("/login"),
List.of(
new FormLoginFilter(userDetailsService)
)
);
}
SecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository();

@Bean
public SecurityFilterChain basicAuthenticationSecurityFilterChain() {
return new DefaultSecurityFilterChain(
(httpServletRequest) -> httpServletRequest.getRequestURI().equals("/members"),
List.of(
new BasicAuthenticationFilter(userDetailsService)
new SecurityContextHolderFilter(contextRepository),
new UsernamePasswordAuthenticationFilter(userDetailsService, contextRepository),
new BasicAuthenticationFilter(userDetailsService, contextRepository)
Comment on lines +28 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userDetailsService를 인자로 넘기는 것과 authenticationManager를 넘기는 방법도 있을 것 같은데
둘의 차이는 어떻게 생각하시나요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • userDetailsService 의존성 주입
    • UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 둘 다 생성자에서 ProviderManager를 생성해주고 있고 그 결과 동일한 역할을 하는 AuthenticationManager 인스턴스가 2개 생기고 있음.
    • userDetailsService에 대한 의존성이 생기므로, userDetailsService의 변화에 영향을 받는다.
  • authenticationManager 의존성 주입
    • 동일한 역할을 하는 AuthenticationManager가 싱글톤으로 관리된다.
    • userDetailsService를 의존하지 않게 되므로, userDetailsService의 변화에 영향을 받지 않고 authenticationManager에게 인증을 맡기는 책임에 집중할 수 있게된다.

이 정도 차이가 있을 것 같은데 혹시 의도하신게 이 부분일까요..!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, userDetailsService 대신 AuthenticationManager를 주입해주면 어떨까 하는 코멘트였습니다 :)

)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import nextstep.security.filter.UserDetails;
import nextstep.security.filter.UserDetailsService;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.Optional;

Expand All @@ -18,6 +19,10 @@ public UserDetailsServiceImpl(MemberRepository memberRepository) {

@Override
public Optional<UserDetails> findUserDetailsByUsername(String username) {
if (!StringUtils.hasLength(username)) {
return Optional.empty();
}

return memberRepository.findByEmail(username)
.map(member -> new UserDetails(member.getEmail(), member.getPassword()));
}
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/nextstep/security/AuthenticationException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package nextstep.security;

public class AuthenticationException extends Exception{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exception을 상속한 이유가 있나요?
checked exception과 unchecked exception에 대해 고민해보셨나요?

Copy link
Author

@haero77 haero77 Feb 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서는 메서드를 호출하는 쪽에서 명시적으로 예외를 핸들링하게 하기 위해서 사용했습니다.
AuthenticationProvider -> AuthenticationManager -> AuthenticationFilter 이렇게 예외가 올라오도록 설계한 후, AuthenticationFilter 에서 예외를 처리하는 방식을 택했습니다.

AuthenticationFilter에게는 AuthenticationException까지 핸들링하는 책임이 있다고 생각해서, AuthenticationFilter 하위 레이어인 AuthenticationManager, AuthenticationProvider 에서 예외를 체크 예외로 던지는게 낫다고 생각했어요. 언체크 예외를 던지면 AuthenticationException이 발생했는지 눈치 채기가 어렵다 보니까, 일부러 이런 방식을 택했습니다.

시큐리티 코드를 보니 실제로는 ProviderManager에서 예외를 핸들링 해주고 있는데, 미션에서는 별도의 예외 처리 레이어를 두기가 애매하더라고요. 그래서 HttpServletRequest를 알고 있는 AuthenticationFilter 선에서 예외 핸들링을 마치려고 했던 것도 있습니다..!

(질문 주신 의도에 맞는 답변일지 궁금하네요..!)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

체크드 익셉션을 사용할 경우 의도하지 않은 곳에서도 throw를 통해 넘겨주어야 하기 때문에 가급적이면 언체크드 익셉션을 사용하기를 권장합니다.

우선 실제 시큐리티 코드에서는 예외 처리를 별도의 필터에서 진행하기 때문에 중간 단계에서는 예외 처리에 대해 고민하지 않도록 하기 위해서라도 언체크드 익셉션을 활용한다고 볼 수 있습니다!


public AuthenticationException() {
super();
}

public AuthenticationException(String message) {
super(message);
}
}
10 changes: 10 additions & 0 deletions src/main/java/nextstep/security/authentication/Authentication.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package nextstep.security.authentication;

public interface Authentication {

Object getCredentials();

Object getPrincipal();

boolean isAuthenticated();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package nextstep.security.authentication;

import nextstep.security.AuthenticationException;

@FunctionalInterface
public interface AuthenticationManager {

/**
* Attempts to authenticate the passed {@link Authentication} object, returning a
* fully populated <code>Authentication</code> object if successful.
* @param authentication the authentication request object
* @return a fully authenticated object including credentials
* @throws AuthenticationException if authentication fails
*/
Comment on lines +8 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석도 가져오신건가요?ㅋㅋㅋ

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다 ㅎㅎ 시큐리티 코드 분석을 하다 보니 복잡한 구현으로 인해 제가 컨텍스트를 종종 놓치더라고요.
과정을 진행하면서 최소한 이 레이어에서는 어떤 작업이 이루어져야겠구나를 꼭 챙겨가고 싶어서, 잘 명세되어있는 주석을 가져와봤습니다!

Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package nextstep.security.authentication;

import nextstep.security.AuthenticationException;

public interface AuthenticationProvider {

/**
* Performs authentication with the same contract as AuthenticationManager.authenticate(Authentication).
* @param authentication the authentication request object.
* @return a fully authenticated object including credentials.
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication) throws AuthenticationException;

boolean supports(Class<?> authentication);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package nextstep.security.authentication;

import nextstep.security.AuthenticationException;
import nextstep.security.filter.UserDetails;
import nextstep.security.filter.UserDetailsService;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import java.util.Optional;

public class DaoAuthenticationProvider implements AuthenticationProvider {

private final UserDetailsService userDetailsService;

public DaoAuthenticationProvider(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, "Only UsernamePasswordAuthenticationToken is supported");

String username = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();

if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
throw new AuthenticationException("username or password is empty");
}

Optional<UserDetails> userDetailsOpt = userDetailsService.findUserDetailsByUsername(username);
if (userDetailsOpt.isEmpty()) {
throw new AuthenticationException("User not found");
}

UserDetails userDetails = userDetailsOpt.get();
if (!userDetails.matchesPassword(password)) {
throw new AuthenticationException("username or password invalid");
}

// matches password. authenticated.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 주석은 어떤 의미인가요?

Copy link
Author

@haero77 haero77 Feb 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!userDetails.matchesPassword(password)) {
    throw new AuthenticationException("username or password invalid");
}

// matches password. authenticated.

'비밀번호에 대한 검증을 끝냈으니, 이제 비밀번호는 일치하고 인증이 된 것이다. 그러니까 인증된 Authentication을 리턴하면 된다.' 라는 의미를 남기고 싶었는데, 지금 보니 컨텍스트가 생략되었네요. 주석을 달더라도 조금 더 컨텍스트를 전달하는, 의미 있는 주석을 달 수 있도록 해봐야겠습니다!

return UsernamePasswordAuthenticationToken.authenticated(authentication);
}

@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nextstep.security.authentication;

import nextstep.security.AuthenticationException;

import java.util.List;

public class ProviderManager implements AuthenticationManager {

private final List<AuthenticationProvider> providers;

public ProviderManager(List<AuthenticationProvider> providers) {
this.providers = providers;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
for (AuthenticationProvider provider : providers) {
if (provider.supports(authentication.getClass())) {
return provider.authenticate(authentication);
}
}

return authentication;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package nextstep.security.authentication;

public class UsernamePasswordAuthenticationToken implements Authentication {

private final String username;
private final String password;
private final boolean authenticated;

private UsernamePasswordAuthenticationToken(String username, String password, boolean authenticated) {
this.username = username;
this.password = password;
this.authenticated = authenticated;
}

public static UsernamePasswordAuthenticationToken unAuthenticated(String username, String password) {
return new UsernamePasswordAuthenticationToken(username, password, false);
}

public static UsernamePasswordAuthenticationToken authenticated(Authentication authentication) {
return new UsernamePasswordAuthenticationToken(
(String) authentication.getPrincipal(),
(String) authentication.getCredentials(),
true
);
}

@Override
public Object getCredentials() {
return this.password;
}

@Override
public Object getPrincipal() {
return this.username;
}

@Override
public boolean isAuthenticated() {
return this.authenticated;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package nextstep.security.context;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

public class HttpSessionSecurityContextRepository implements SecurityContextRepository {

public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT";

// todo: SecurityContextRepository의 loadContext와 saveContext 동작을 각각 테스트.
@Override
public SecurityContext loadContext(HttpServletRequest request) {
SecurityContext securityContext = readSecurityContextFromSession(request.getSession());

if (securityContext == null) {
return SecurityContextHolder.createEmptyContext();
}

return securityContext;
}

@Override
public void saveContext(
SecurityContext context,
HttpServletRequest request,
HttpServletResponse response
) {
HttpSession session = request.getSession();
session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
}

private SecurityContext readSecurityContextFromSession(HttpSession session) {
if (session == null) {
return null;
}

// Session Exists.
Object securityContextFromSession = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
if (securityContextFromSession == null) {
return null;
}

if (!(securityContextFromSession instanceof SecurityContext)) {
return null;
}

// Everything Cool! SecurityContext Exists.
return (SecurityContext) securityContextFromSession;
}
}
12 changes: 12 additions & 0 deletions src/main/java/nextstep/security/context/SecurityContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package nextstep.security.context;

import nextstep.security.authentication.Authentication;

import java.io.Serializable;

public interface SecurityContext extends Serializable {

Authentication getAuthentication();

void setAuthentication(Authentication authentication);
}
29 changes: 29 additions & 0 deletions src/main/java/nextstep/security/context/SecurityContextHolder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package nextstep.security.context;

public class SecurityContextHolder {

private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

public static SecurityContext getContext() {
SecurityContext context = contextHolder.get();

if (context == null) {
context = createEmptyContext();
contextHolder.set(context);
}

return context;
}

public static void setContext(SecurityContext context) {
contextHolder.set(context);
}

public static void clearContext() {
contextHolder.remove();
}

public static SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package nextstep.security.context;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.Assert;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

public class SecurityContextHolderFilter extends OncePerRequestFilter {

private final SecurityContextRepository securityContextRepository;

public SecurityContextHolderFilter(SecurityContextRepository securityContextRepository) {
Assert.notNull(securityContextRepository, "securityContextRepository cannot be null");
this.securityContextRepository = securityContextRepository;
}

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// contextRepository를 통해 SecurityContext를 가져온다. 없을 경우 empty context.
SecurityContext context = this.securityContextRepository.loadContext(request);

try {
SecurityContextHolder.setContext(context);
filterChain.doFilter(request, response);
} finally {
// 요청이 끝나고 SecurityContext를 clear. (HTTP Session에는 SecurityContext가 남아있을 수 있음)
SecurityContextHolder.clearContext();
}
}
}
25 changes: 25 additions & 0 deletions src/main/java/nextstep/security/context/SecurityContextImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nextstep.security.context;

import nextstep.security.authentication.Authentication;

public class SecurityContextImpl implements SecurityContext {

private Authentication authentication;

public SecurityContextImpl() {
}

public SecurityContextImpl(Authentication authentication) {
this.authentication = authentication;
}

@Override
public Authentication getAuthentication() {
return this.authentication;
}

@Override
public void setAuthentication(Authentication authentication) {
this.authentication = authentication;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nextstep.security.context;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public interface SecurityContextRepository {

SecurityContext loadContext(HttpServletRequest request);

void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response);
}
Loading