4. Spring Security

지니🧸·2023년 10월 6일
0

YC Tech Academy

목록 보기
5/5

연세대학교 미래교육원 x 코드프레소의 YC Tech Academy를 수강하기 위해 레퍼런스의 자료로 예습한 개인 노트입니다.

User Registration

8.1 UserDetailsService

@Service
@Transactional
public class MyUserDetailsService implements UserDetailsService {
	@Autowired
    private UserRepository userRepository;
    
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    	User user = userRepository.findByEmail(email);
        if (user == null) {
        	throw new UsernameNotFoundException("No user found with username: " + email);
        }
        boolean enabled = true;
        boolean accountNonExpired = true;
        boolean credentialsNonExpired = true;
        boolean accountNonLocked = true;
        
        return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, getAuthorities(user.getRoles()));
    }
    
    private static List<GrantedAuthority> getAuthorities(List<String> roles) {
    	List<GrantedAuthority> authorities = new ArrayList<>();
        
        for (String role : roles) {
        	authorities.add(new SimpleGrantedAuthority(role));
        }
        return authorities;
    }
}

8.2 Enable the new authentication provider

Add a reference to the UserDetailsService inside the authentication-manager element & add the UserDetailsService bean to enable the new user service in the Spring Security configuration.

@Autowired
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
	auth.userDetailsSErvice(userDetailsService);
}

Reference: User Registration


Registration - Activate a new account by Email

Verification token

1. Entity

The VerificationToken entity must meet the following criteria:

  1. must link back to User
  2. created right after registration
  3. expires within 24 hours after creation
  4. has unique, randomly generated value
@Entity
public class VerificationToken {
	private static final int EXPIRATION = 60 * 24;
    
    @Id
    @GeneratedValue(straetgy = GenerationType.AUTO)
    private Long id;
    
    private String token;
    
    @OneToOne(targetEntity = User.class ,fetch = FetchType.EAGER)
    @JoinColumn(nullable = false, name = "user_id")
    private User user;
    
    private Date expiryDate;
    
    private Date calculateExpiryDate(int expiryTimeInMinutes) {
    	Calendar cal = Calendar.getInstance();
        cal.setTime(new Timestamp(cal.getTime().getTime()));
        cal.add(Calendar.MINUTE, expiryTimeInMinutes);
        return new Date(cal.getTime().getTime());
    }
    
    // constructors, getters, etc.
}

nullable=false ensures data integrity & consistency across VerificationToken and User association

2. add field to User entity

public class User {
	...
    @Column(name = "enabled")
    private boolean enabled;
    
    public User() {
    	super();
        this.enabled = false;
    }
    ...
}

enabled field

  • default value: false
  • if account verification process is successful, becomes true

Account registration process

  1. generate & persist VerificationToken for User
  2. send out email message for account confirmation (includes confirmation link w/ VerificationToken's value

3.1 Spring Event

In this context, Spring Event will create the token & send verification email

Controller publishes a Spring ApplicationEvent to trigger the execution of these tasks

  1. inject ApplicationEventPublisher
  2. use it to publish the registration completion
@Autowired
ApplicationEventPublisher eventPublisher;

@PostMapping("/user/registration")
public ModelAndView registerUserAccount(@ModelAttribute("user") @Valid UserDto userDto, HttpServletRequest request, Errors errors) {
	try {
    	User registered = userService.registerNewUserAccount(userDto);
        
        String appUrl = request.getContextPath();
        eventPublisher.publishEvent(new OnRegistrationCompleteEvent(registered, request.getLocale(), appUrl));
    } catch (UserAlreadyExistException uaeEx) {
    	ModelAndView mav =new ModelAndView("registration", "user", userDto);
        mav.addObject("message", "An account for that username/email already exists.");
        return mav;
    } catch (RuntimeException ex) {
    	return new ModelAndView("emailError", "user", userDto);
    }
    return new ModelAndView("successRegister", "user", userDto);
}

ModelAndView:

  • class in org.springframework.web.servlet package
  • value object designed to hold model and view
  • make it possible for a handler to return both model and view in a single return value

3.2 Event & Listener

Application event: a uniquely named situation that can be triggered by an action performed by somebody working in the system or by a condition taht occurs while the system is running

OnRegistrationCompleteEvent

public class OnRegistrationCompleteEvent extends ApplicationEvent {
	private String appUrl;
    private Locale locale;
    private User user;
    
    public OnRegistrationCompleteEvent(User user, Locale locale, String appUrl) {
    	super(user);
        this.user = user;
        this.locale = locale;
        this.appUrl = appUrl;
    }
}

RegistrationListener will handle OnRegistrationCompleteEvent

@Component
public class RegistrationListener implements ApplicationListener<OnRegistrationCompleteEvent> {
	@Autowired
    private IUserService service;
    
    @Autowired
    private MessageSource messages;
    
    @Autowired
    private JavaMailSender mailSender;
    
    @Override
    public void onApplicationEvent(OnRegistrationCompleteEvent event) {
    	this.confirmRegistration(event);
    }
    
    private void confirmRegistration(OnRegistrationCompleteEvent event) {
    	User user = event.getUser();
        String token = UUID.randomUUID().toString();
        service.createVerificationToken(user, token);
        
        String recipientAddress = user.getEmail();
        String subject = "Registration Confirmation";
        String confirmationUrl = event.getAppUrl() + "/registrationConfirm?token=" + token;
        String message = messages.getMessage("message.regSucc", null, event.getLocale());
        
        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(message + "\r\n" + "http://localhost:8080" + confirmationUrl);
        mailSender.send(email);
    }
}
  • confirmRegistration method:
    • receives OnRegistrationCompleteEvent
    • takes necessary User information from it
    • creates & persists verification token
    • sends token as paramter in confirm registration link
  • JavaMailSender may throw javax.mail.AuthenticationFailedException

3.3 Processing the verification token parameter

When the user receives the confirm registration link, they should click on it.

When the user clicks on the link, the controller:

  • extracts the value of the token parameter in the resulting GET request
  • use it to enable the User

RegistrationController processes the registration confirmation

@Autowired
private IUserService service;

@GetMapping("/registrationConfirm")
public String confirmRegistration(WebRequest request, Model model, @RequestParam("token") String token) {
	Locale locale = request.getLocale();
    
    VerificationToken verificationToken = service.getVerificationToken(token);
    if (verificationToken == null) {
    	String message = messages.getMessage("auth.message.invalidToken", null, locale);
        model.addAttribute("message", message);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    }
    
    User user = verificationToken.getUser();
    Calendar cal = Calendar.getInstance();
    if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
    	String messageValue = messages.getMessage("auth.message.expired", null, locale);
        model.addAttribute("message", messageValue);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    }
    
    user.setEnabled(true);
    service.saveRegisteredUser(user);
    return "redirect:/login.html?lang=" + request.getLocale().getLanguage();
}
  • 토큰이 존재하지 않으면 invalid token
  • 토큰이 만료되었으면 message expired

Locale object: represents a specific geographical, political, or cultural region

4. Adding account activation checking to login process

It is necessary to check if the user is enabled. We implement this through the loadUserByUsername method in MyUserDetailsService

@Autowired
UserRepository userRepository;

public UserDetails loadUserByUsername(String email throws UsernameNotFoundException {
	boolean enabled = true;
    boolean accountNonExpired = true;
    boolean credentialsNonExpired = true;
    boolean accountNonLocked = true;
    try {
    	User user = userRepository.findByEmail(email);
        if (user == null) {
        	throw new UsernameNotFoundException("No user found with username: " + email);
        }
        
        return new org.springrframework.security.core.userdetails.User(
        	user.getEmail(),
            user.getPassword().toLowerCase(),
            user.isEnabled(),
            accountNonExpired,
            credentialsNonExpired,
            accountNonLocked,
            getAuthorities(user.getRole()));
    } catch (Exception e) {
    	throw new RuntimeException(e);
    }
}

AuthenticationFailureHandler: customizes the exception messages coming from the above class

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
	
    @Autowired
    private MesasgeSource messages;
    
    @Autowired
    private LocaleResolver localeResolver;
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    	setDefaultFailureUrl("/login.html?error=true");
        super.onAuthenticationFailure(request, response, exception);
        Locale locale = localeResolver.resolveLocale(request);
        String errorMessage = messages.getMessage("message.badCredentials", null, locale);
        
        if (exception.getMessage().equalsIgnoreCase("User is disabled")) {
        	errorMessage = messages.getMessage("auth.message.disabled", null, locale);
        } else if (exception.getMessage().equalsIgnoreCase("User account has expired")) {
        	errorMessage = messages.getMessage("auth.message.expired", null, locale);
        }
        
        request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, errorMessage);
    }
}

5. IUserService & UserService

IUserService

public interface IUserService {
	User registerNewUserAccount(UserDto userDto) throws UserAlreadyExistException;
    
    User getUser(String verificationToken);
    
    void saveRegisteredUser(User user);
    
    void createVerificationToken(User user, String token);
    
    VerificationToken getVerificationToken(String VerificationToken);
}

UserService

@Service
@Transactional
public class UserService implements IUserService {
	
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private VerificationTokenRepository tokenRepository;
    
    @Override
    public User registerNewUserAccount(UserDto userDto) throws UserAlreadyExistException {
    	if (emailExist(userDto.getEmail())) {
        	throw new UserAlreadyExistException("There is an account with that email address: " + userDto.getEmail());
        }
        
        User user = new User();
        user.setFirstName(userDto.getFirstName());
        user.setLastName(userDto.getLastName());
        user.setPassword(userDto.getPassword());
        user.setEmail(userDto.getEmail());
        user.setRole(new Role(Integer.valueOf(1), user));
        return repository.save(user);
    }
    
    private boolean emailExist(String email) { 
    	return userRepository.findByEmail(email) != null;
    }
    
    @Override
    public User getUser(String verificationToken) {
    	User user = tokenRepository.findByToken(verificationToken).getUser();
        return user;
    }
    
    @Override
    public VerificationToken getVerificationToken(String verificationToken) {
    	return tokenRepository.findByToken(VerificationToken);
    }
    
    @Override
    public void saveRegisteredUser(User user) {
    	userRepository.save(user);
    }
    
    @Override
    public void createVerificationToken(User user, String token) {
    	VerificationToken myToken = new VerificationToken(token, user);
        tokenRepository.save(myToken);
    }
}

References


Resend Verification Email

@GetMapping("/user/resendRegistrationToken")
public GenericResponse resendRegistrationToken(HttpServletRequest request, @RequestParam("token") String existingToken) {
	VerificationToken newToken = userService.generateNewVerificationToken(existingToken);
    
    User user = userService.getUser(newToken.getToken());
    String appUrl = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath();
    
    SimpleMailMessage email = constructResendVerificationTokenEmail(appUrl, request.getLocale(), newToken, user);
    mailSender.send(email);
    
    return new GenericResponse(messages.getMessage("message.resendToken", null, request.getLocale()));
}
  • when the user requests another verification link
  • reset existing token w/ a new expireDate
  • send the user a new email w/ new link/token

3. Exception Handler

Custom exception handler

@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
	@Autowired
    private MessageSource messages;
    
    @ExceptionHandler({ UserNotFoundException.class })
    public ResponseEntity<Object> handleUserNotFound(RuntimeException ex, WebRequest request) {
    	logger.error("404 Status Code", ex);
        GenericResponse bodyOfResponse = new GenericResponse(messages.getMessage("message.userNotFound", null, request.getLocale()), "UserNotFound");
        
        return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.NOT_FOUND, request);
}
	...
  • @ControllerAdvice: annotation to handle exceptions across whole application
  • GenericResponse: simple response object custom made in this example
    • fields: message, error

References


Architecture

1. A review of Filters

Servlet Filters가 스프링 시큐리티의 Servlet 지원의 기반이다. 그럼 Filters의 역할은 무엇일까?

아래 이미지로 HTTP 요청을 처리하는 핸들러들의 계층을 확인해보자

[이미지 출처]

HTTP 요청 처리 과정

  1. 클라이언트가 애플리케이션에 요청을 보낸다
  2. 컨테이너가 다음 두가지를 담은 FilterChain을 생성한다:
    • Filter 인스턴스들
    • 요청 URI에 따라 HttpServletRequest를 처리할 Servlet

스프링부트 애플리케이션에서 ServletDispatcherServlet의 인스턴스로, 최대 한개의 Servlet이 하나의 HttpServletRequestHttpServletResponse를 처리할 수 있다.

Filter의 역할

  1. 후행 Filter 인스턴스 또는 Servlet이 불리지 않도록 방지한다. 이 경우에는 HttpServletResponse를 작성한다
  2. Filter 인스턴스와 Servlet이 사용하는 HttpServletRequest 또는 HttpServletResponse을 수정한다

Filter의 권한은 넘겨받는 FilterChain이 결정한다

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	// do something before the rest of the application
    chain.doFilter(request, response); // invoke the rest of the application
    // do something after the rest of the application
}

Filter는 후행 Filter 인스턴스와 Servlet만을 관리할 수 있기 때문에 각 Filter가 어느 순서로 불리는지가 매우 중요하다

2. DelegatingFilterProxy

DelegatingFilterProxy

  • 스프링이 제공하는 Filter의 구현
  • 서블렛 컨테이너의 lifecycle와 스프링의 ApplicationContext를 잇는 역할
    • 서블렛 컨테이너는 Filter 인스턴스의 등록은 받을 수 있지만, 스프링이 정의한 빈에 대해서는 반응하지 못한다
  • DelegatingFilterProxy를 표준 서블렛 컨테이너 방식으로 등록하고, Filter를 구현하는 스프링빈에 일을 넘긴다


[이미지 출처]

Pseudocode

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	Filter delegate = getFilterBean(someBeanName);
	delegate.doFilter(request, response);
}
    1. Lazy로 스프링빈으로 등록된 Filter를 가져온다
    1. 스프링 빈에 일을 넘긴다

작동 과정

  1. DelegatingFilterProxyApplicationContext에서 Bean Filter_0을 찾는다
  2. 찾으면 Bean Filter_0을 부른다

Filter 빈 인스턴스 찾기를 Lazy하기 수행하기 때문에 Filter 인스턴스가 등록되어야할 때 ContextLoaderListener로 스프링빈을 로드한다

3. FilterChainProxy

FilterChainProxy

  • 스프링 시큐리티가 제공하는 특별한 Filter
  • SecurityFilterChain을 통해 여러 Filter 인스턴스에 일을 넘긴다
  • 빈이기 때문에 주로 DelegatingFilterProxy에 랩핑(wrapping)되어 있음


[이미지 출처]

4. SecurityFilterChain

SecurityFilterChain

  • FilterChainProxySecurityFilterChain으로 이 요청에는 어느 스프링 시큐리티 Filter 인스턴스를 불러야 할지 판단한다


[이미지 출처]

  • SecurityFilterChain의 security filter는 주로 FilterChainProxy로 등록된 빈이다
    • 디버깅하기 더 쉽다
    • 메모리 누수 방지를 위해 Security Context를 비운다
    • invoke 시간이 더 유연하다

위 사진과 같이 SecurityFilterChain이 여러 개 함께 있는 경우에는 FilterCHainProxy가 어느 SecurityFilterChain을 사용할지 결정한다
(첫 매칭을 주로 사용한다)

5. Security Filters

Security Filter

  • SecurityFilterChain API로 FilterChainProxy에 입력됨
  • 기능: authentication, authorization, exploit protection, etc.
  • 각 필터가 알맞은 타이밍에 수행되도록 순서에 맞게 수행된다
    • (예) authentication 필터가 authorization 필터보다 먼저 수행된다
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

}

6. Filter Chain에 커스텀 Filter 추가하기

  1. 커스텀 필터를 만든다
public class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String tenantId = request.getHeader("X-Tenant-Id"); 
        boolean hasAccess = isUserAllowed(tenantId); 
        if (hasAccess) {
            filterChain.doFilter(request, response); 
            return;
        }
        throw new AccessDeniedException("Access denied"); 
    }

}
  1. 필터를 Filter chain에 추가한다
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterBefore(new TenantFilter(), AuthorizationFilter.class); 
    return http.build();
}

필터 체인 수행 과정

새 필터 (TenantFilter)가 AuthorizationFilter 앞 순서로 추가됐음으로 AuthenticationFilter 다음 순서로 수행될 것임

Security Exceptions

  • AccessDeniedException
  • AuthenticationException
  • ExceptionTranslationFilter: 위 exception을 HTTP response으로 번역한다
    • 하나의 security filter로 FilterChainProxy에 추가됨

  1. ExceptionTranslationFilterFilterChain.doFilter(request, response)를 호출하면, doFilter가 나머지 애플리케이션을 호출한다
  2. 인증되지 않은 사용자(AuthenticationException)면 인증을 시작한다
    • SecurityContextHolder를 비운다
    • HttpServlet을 저장한다 (인증 성공시 기존 요청 수행)
    • AuthenticationEntryPoint으로 클라이언트로부터 인증정보를 받아온다 (로그인 페이지 등)
  3. 접근이 금지된 사용자(권한 부족 등)면 AccessDeniedHandler가 호출된다
try {
	filterChain.doFilter(request, response); 
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		startAuthentication(); 
	} else {
		accessDenied(); 
	}
}

인증간 요청 저장

인증을 필요로 하는 리소스에 인증 없이 요청이 들어오면, 이 요청을 저장해두고 인증 성공시 요청을 수행해야 한다

요청 저장 => RequestCache 구현으로 HttpServletRequest을 저장한다

RequestCache

  • HttpServletRequestRequestCache에 저장된다
  • 사용자가 인증을 성공하면 RequestCacheAwareFilterRequestCache로 저장된 요청을 재수행한다
    • continue paramter가 있어야지만 저장된 요청을 확인한다
@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
	HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
	requestCache.setMatchingRequestParameterName("continue");
	http
		// ...
		.requestCache((cache) -> cache
			.requestCache(requestCache)
		);
	return http.build();
}

요청을 저장하지 않고 싶으면 NullRequestCache

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    RequestCache nullRequestCache = new NullRequestCache();
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(nullRequestCache)
        );
    return http.build();
}

References:

profile
우당탕탕

0개의 댓글