Java/spring

spring security를 알아보자 - 2

티코딩 2024. 3. 12. 17:03

ㅇ Spring Seocurity의 내부구조

https://medium.com/@greekykhs/springsecurity-part-3-spring-security-flow-7da9cc3624ab

1. 가장 먼저 유저가 로그인정보(자격증명)를 입력해서 클라이언트가 백엔드 웹 애플리케이션에 전송함.

2. 20개이상의 필터가있는 SpringSecurity 필터를 통하는데, 여기서 인증객체(username과 자격증명만 보유)로 변환시키고

3. Authentication Manager한테 보낸다.

4. 실질적인 인증로직을 관리하는 Authentication Manager는 웹애플리케이션 안에 무슨 인증제공자가 존재하는지확인하고 전달함.

5. 유저의 자격증명을 검증하기위해 UserDetailsManager로 보내고,

6. 비밀번호를 검증하기위해 PsswordEncoder로 보낸다.

**만약 여기서 검증이 실패하면 Authentication Manager가 유저에게 인증실패를 응답한다.

7. Authentication Providers의 프로세싱이 끝나면 다시 Authentication Manager에게 넘긴다.

8. Authentication Manager은 다시 Spring Security Filters로 들어간다.

9. 응답을 유저에게 전달하기전에 2단계에서 변환한 인증객체를 Security Context에 저장(인증성공여부, 세션ID)한다.

**여기서 성공했다면 두번째로그인부터(세션이 끝나지 않았다면) 로그인요청을 하지않을것이다.

10. api나 유저가 요청한걸 이제서야 응답해준다.

 

 

ㅇ 주요필터

 

1. AuthorizationFilter

유저가 접근하고자하는 URL에 접근을 제한

@Override
	public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
			throws ServletException, IOException {

		HttpServletRequest request = (HttpServletRequest) servletRequest;
		HttpServletResponse response = (HttpServletResponse) servletResponse;

		if (this.observeOncePerRequest && isApplied(request)) {
			chain.doFilter(request, response);
			return;
		}

		if (skipDispatch(request)) {
			chain.doFilter(request, response);
			return;
		}

		String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
		request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
		try {
			AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
			this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
			if (decision != null && !decision.isGranted()) {
				throw new AccessDeniedException("Access Denied");
			}
			chain.doFilter(request, response);
		}
		finally {
			request.removeAttribute(alreadyFilteredAttributeName);
		}
	}

AuthorizationFilter내부엔 doFilter라는 메서드가 존재한다.

AuthorizationMager의 도움을 받아 특정 URL이 public URL인지, security URL인지를 체크한 후, URL에 접근을 허용하거나 거부한다. 여기서 만약 security URL에 접근하려고하면 DefaultLoginPageGenerating 필터로 넘어간다.

여기에선 generateLoginPageHtml 메서드가있는데,  security URL에 접근하려고하면 바로 로그인페이지가 표시되는데 이 메서드 덕분이다.

private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) {
		String errorMsg = loginError ? getLoginErrorMessage(request) : "Invalid credentials";
		String contextPath = request.getContextPath();
		StringBuilder sb = new StringBuilder();
		sb.append("<!DOCTYPE html>\n");
		sb.append("<html lang=\"en\">\n");
		sb.append("  <head>\n");
		sb.append("    <meta charset=\"utf-8\">\n");
		sb.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
		sb.append("    <meta name=\"description\" content=\"\">\n");
		sb.append("    <meta name=\"author\" content=\"\">\n");
		sb.append("    <title>Please sign in</title>\n");
		sb.append("    <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css\" "
				+ "rel=\"stylesheet\" integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" crossorigin=\"anonymous\">\n");
		sb.append("    <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" "
				+ "rel=\"stylesheet\" integrity=\"sha384-oOE/3m0LUMPub4kaC09mrdEhIc+e3exm4xOGxAmuFXhBNF4hcg/6MiAXAf5p0P56\" crossorigin=\"anonymous\"/>\n");
		sb.append("  </head>\n");
		sb.append("  <body>\n");
		sb.append("     <div class=\"container\">\n");
		if (this.formLoginEnabled) {
			sb.append("      <form class=\"form-signin\" method=\"post\" action=\"" + contextPath
					+ this.authenticationUrl + "\">\n");
			sb.append("        <h2 class=\"form-signin-heading\">Please sign in</h2>\n");
			sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "        <p>\n");
			sb.append("          <label for=\"username\" class=\"sr-only\">Username</label>\n");
			sb.append("          <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter
					+ "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
			sb.append("        </p>\n");
			sb.append("        <p>\n");
			sb.append("          <label for=\"password\" class=\"sr-only\">Password</label>\n");
			sb.append("          <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter
					+ "\" class=\"form-control\" placeholder=\"Password\" required>\n");
			sb.append("        </p>\n");
			sb.append(createRememberMe(this.rememberMeParameter) + renderHiddenInputs(request));
			sb.append("        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
			sb.append("      </form>\n");
		}
		if (this.oauth2LoginEnabled) {
			sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
			sb.append(createError(loginError, errorMsg));
			sb.append(createLogoutSuccess(logoutSuccess));
			sb.append("<table class=\"table table-striped\">\n");
			for (Map.Entry<String, String> clientAuthenticationUrlToClientName : this.oauth2AuthenticationUrlToClientName
				.entrySet()) {
				sb.append(" <tr><td>");
				String url = clientAuthenticationUrlToClientName.getKey();
				sb.append("<a href=\"").append(contextPath).append(url).append("\">");
				String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
				sb.append(clientName);
				sb.append("</a>");
				sb.append("</td></tr>\n");
			}
			sb.append("</table>\n");
		}
		if (this.saml2LoginEnabled) {
			sb.append("<h2 class=\"form-signin-heading\">Login with SAML 2.0</h2>");
			sb.append(createError(loginError, errorMsg));
			sb.append(createLogoutSuccess(logoutSuccess));
			sb.append("<table class=\"table table-striped\">\n");
			for (Map.Entry<String, String> relyingPartyUrlToName : this.saml2AuthenticationUrlToProviderName
				.entrySet()) {
				sb.append(" <tr><td>");
				String url = relyingPartyUrlToName.getKey();
				sb.append("<a href=\"").append(contextPath).append(url).append("\">");
				String partyName = HtmlUtils.htmlEscape(relyingPartyUrlToName.getValue());
				sb.append(partyName);
				sb.append("</a>");
				sb.append("</td></tr>\n");
			}
			sb.append("</table>\n");
		}
		sb.append("</div>\n");
		sb.append("</body></html>");
		return sb.toString();
	}

	private String getLoginErrorMessage(HttpServletRequest request) {
		HttpSession session = request.getSession(false);
		if (session == null) {
			return "Invalid credentials";
		}
		if (!(session
			.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) instanceof AuthenticationException exception)) {
			return "Invalid credentials";
		}
		if (!StringUtils.hasText(exception.getMessage())) {
			return "Invalid credentials";
		}
		return exception.getMessage();
	}


유저가만약 이 로그인페이지에서 자격증명을 입력하고나면 다음 필터는 UsernamePasswordAuthenticationFilter다.

여기에는 attemptAuthentication이라는 메서드가 있는데, 이 필터의 주요책임은 수신하는 HTTP의 출력요청으로부터 유저네임,패스워드를 추출하는것이다. UsernamePasswordAuthenticationToken 객체를 생성한다. 위에서 2단계에 우리는 Authentication 객체를생성한다고 했는데, UsernamePasswordAuthenticationFilter의 상속을 타고타고가다보면 부모에 Authentication 인터페이스를 implements 한다. 고로 AuthenticationManager의 authenticate메서드로 객체를 넘긴다.

@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

 

그리고 ProviderManager 라는 클래스가 있는데 이 클래스는 AuthenticationManager인터페이스를 구현한다.

이 클래스에는 authenticate 라는 메서드를 오버라이딩하고있다.

@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		if (result == null && this.parent != null) {
			// Allow the parent to try.
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}
			catch (ProviderNotFoundException ex) {
				// ignore as we will throw below if no other exception occurred prior to
				// calling parent and the parent
				// may throw ProviderNotFound even though a provider in the child already
				// handled the request
			}
			catch (AuthenticationException ex) {
				parentException = ex;
				lastException = ex;
			}
		}
		if (result != null) {
			if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
				// Authentication is complete. Remove credentials and other secret data
				// from authentication
				((CredentialsContainer) result).eraseCredentials();
			}
			// If the parent AuthenticationManager was attempted and successful then it
			// will publish an AuthenticationSuccessEvent
			// This check prevents a duplicate AuthenticationSuccessEvent if the parent
			// AuthenticationManager already published it
			if (parentResult == null) {
				this.eventPublisher.publishAuthenticationSuccess(result);
			}

			return result;
		}

		// Parent was null, or didn't authenticate (or throw an exception).
		if (lastException == null) {
			lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
					new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
		}
		// If the parent AuthenticationManager was attempted and failed then it will
		// publish an AbstractAuthenticationFailureEvent
		// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
		// parent AuthenticationManager already published it
		if (parentException == null) {
			prepareException(lastException, authentication);
		}
		throw lastException;
	}

ProviderManager는 AuthenticationManager의 구현이며 프레임워크 내의 모든 사용가능한 AuthenticationProvider 또는 개발자가 정의한 AuthenticationProvider와 상호작용을한다. 위의 메서드에서 향상된 for문이 있는데 여기서 모든 적용가능한  AuthenticationProvider를 반복한다. 모든 인증과 권한 부여의 로직은 AuthenticationProvider 내부에 있다. 

 

ProviderManager는 spring security의 AuthenticationProvider중 하나를 호출하는데 그것이 바로 DaoAuthenticationProvider이다. 여기서 모든 실제인증로직이 반환된다.

여기엔 retrieveUser메서드가 있다.

@Override
	protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
			if (loadedUser == null) {
				throw new InternalAuthenticationServiceException(
						"UserDetailsService returned null, which is an interface contract violation");
			}
			return loadedUser;
		}
		catch (UsernameNotFoundException ex) {
			mitigateAgainstTimingAttack(authentication);
			throw ex;
		}
		catch (InternalAuthenticationServiceException ex) {
			throw ex;
		}
		catch (Exception ex) {
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
		}
	}

여기서 UserDetailsManager의 구현체중 하나로부터 도움을 받는다. 바로 InMemoryUserDetailsManager.

여기엔 loadUserByUsername 이란메서드로 UserDetails 객체를 반환한다.

@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		UserDetails user = this.users.get(username.toLowerCase());
		if (user == null) {
			throw new UsernameNotFoundException(username);
		}
		return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
				user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
	}

 

이 클래스는 UserDetailsManager를 구현하는 클래스다. 우리가 유저정보를 애플리케이션의 인메모리 내부에 저장하려고 할때 InMemoryUserDetailsManager 클래스를 사용한다. 우리가 application.properties내부에 설정한대로 위의 메서드에 파라미터에 채워지면 User객체를 반환하게 되는데, retrieveUser메서드가 UserDetails객체로 반환하게된다.

 

ㅇ Spring security 시퀀스

 

ㅁ Spring Security Filters

ㅁ AuthenticationManager(Provider manager)

ㅁ AuthenticationProvider(Dao)

ㅁ UserDetalsManager/PasswordEncoder

이렇게 크게 네파트로 나눈다.

 

1. 유저가 api경로에 접근(security인지 public인지 모름)

2. 유저에의해 요청이 시작되면 Spring Security Filters는 AuthorizationFilter, DefaultLoginPageGeneratingFilter를 통해 요청을가로채고 security 경로면 자격증명을 입력할수있는 로그인페이지를 응답해준다.

3. 자격증명 입력후, 이 요청을 UsernamePasswordAuthenticationFilter를 통해 입력받은걸 UsernamePasswordAuthenticationToken객체로 생성해서 넘김

4. ProviderManager는 이 객체를 받아 Authenticate()메서드호출. 적용가능한 모든 AuthenticationProvider를 찾아 시도함.

5. 이 과정중 DaoAuthenticationProvider는 내부적으로 UserDetailsManager의 구현체인 InMemoryUserDetailsManager클래스의 loadUserByUsername() 메서드를 호출해 메모리에서 유저정보를 불러오고, PasswordEncoder로 비밀번호 일치여부를 판단함.

6. 유저정보가 검증이됐다면, Provider Manager에게 인증이 성공이라는 응답전송

7. Provider manager는 성공이라면 Spring Security Filters에게 전달함.

8. 인증정보를 securityContext 객체내부에 저장해둔다. 그래서 세션이 유효할때까진 자격증명을 요구하지 않는다.