검색

레이블이 보안인 게시물을 표시합니다. 모든 게시물 표시
레이블이 보안인 게시물을 표시합니다. 모든 게시물 표시

2021년 12월 13일

전자정부프레임워크 3.6.0 환경에서 아파치 Log4j 제로데이 취약점 (CVE-2021-44228) 조치 방법

Apache 재단은 보안 취약점 1 ~ 10 구분하고 있는데 이번에 알려진 Apache Log4j 2 에서 발생하는 원격코드 실행 취약점(CVE-2021-44228)은 가장 높음 10단계에 해당하는 아주 심각한 보안 취약점이다.

KISA 에서는 긴급 보안 업데이트 권고 형태로 즉각적인 조치를 권하고있다. (12/13)

https://www.krcert.or.kr/data/secNoticeView.do?bulletin_writing_sequence=36389


스프링 기반의 전자정부 프레임워크를 사용하는 환경우 경우 아래와 같은 절차로 처리가 가능하다.

대상 환경은 아래와 같다. 

  • 전자정부 프레임워크 버전 : 3.6.0 
  • Spring : Spring 4.1.2-RELEASE
  • Java : 1.8 이상


조치 방법

① 전자정부 프레임워크 기반 자바 프로그램을 maven 도구를 사용하도록 되어 있다. 프로젝트에서 Log4J2 의 의존성을 제거하기 위하여 pom.xml 설정에서 아래와 같이 exclusion 을 설정을 추가한다.



 
   	
		3.6.0
    
     
		
			egovframework.rte
			egovframework.rte.psl.dataaccess
			${egovframework.rte.version}
			
				
					org.apache.logging.log4j
					log4j-api
				
				
					org.apache.logging.log4j
					log4j-core
				
				
					org.apache.logging.log4j
					log4j-slf4j-impl
				
				
					org.slf4j
					log4j-over-slf4j
				
			
		 
     
    


② pom.xml 에 최신 LOG4J 2 버전으로 의존성을 추가한다.



 
   	
		3.6.0
		2.15.0
		1.7.32        
    
     
		
			egovframework.rte
			egovframework.rte.psl.dataaccess
			${egovframework.rte.version}
			
				
					org.apache.logging.log4j
					log4j-api
				
				
					org.apache.logging.log4j
					log4j-core
				
				
					org.apache.logging.log4j
					log4j-slf4j-impl
				
				
					org.slf4j
					log4j-over-slf4j
				
			
		 
        
		
		
			org.slf4j
			slf4j-api
			${project.dependency.slf4j.version}
		 
	    
            org.slf4j
            jcl-over-slf4j
            ${project.dependency.slf4j.version}
            runtime
        
 		
			org.apache.logging.log4j
			log4j-api
			${project.dependency.log4j.version}
		        
		
			org.apache.logging.log4j
			log4j-core
			${project.dependency.log4j.version}
		  
        
		
			org.apache.logging.log4j
			log4j-slf4j-impl
			${project.dependency.log4j.version}
		 
        
  		
			org.apache.logging.log4j
			log4j-web
			${project.dependency.log4j.version}
			runtime
		
		
        
     
    


⓷ 기존 프로젝트에서 Spring 에서 제공하는 log4j 설정 기능을 사용하고 있었다면 아래와 같이 web.xml 파일에서 해당 설정을 제거한다.




    



④ 기존 프로젝트를 새롭게 빌드하여 배포한다.

2020년 6월 8일

Spring 기반 웹 프로그램에서 JWT 인증 사용하기

JWT ( JSON WEB TOKEN ) 는 URL 형식으로 구현할 수 있는 전자서명 기술이다. JWT 는 주요한 속성 정보를 RFC7519 표준에 따라 JSON 테이터 구조로 표현한다.

JWT 기술을 활용하면 손쉽게 클라이언트(앱) 기반 프로그램에서 토큰 기반 인증 및 REST API  호출을 구현할 수 있다.

클라이언트와 서버간의 인증 시나리오를 정리하면 아래와 같다. 

❶ 사용자가 로그인을 하면, ❷ 서버는 사용자의 정보를 기반으로한 JWT 토큰을 발급한다.
그 후, ❸ 사용자가 서버에 요청을 할 때 마다 JWT를 포함하여 전달한다. ❹ 서버는 클라이언트에서 요청을 받을때 마다, 해당 토큰이 유효하고 인증됐는지 검증을 하고, 사용자가 요청한 작업에 권한이 있는지 확인하여 작업을 처리.



Spring 기반의 서버 사이드  JWT

다행스러운 것은 JWT 토큰 생성과 검증을 위한 라이브러리가  JWT 사이트에서 다양한 언어 버전으로 제공하고 있어 적절하게 이를 활용하여 손쉽게 JWT 를 적용할 수 있다.



글에서는  "https://github.com/jwtk/jjwt" 를 사용하였다. 


서버 프로그램 개발환경이 maven 으로 구현되어 있어 아래와 같이 라이브러리 의존성을 추가하였다. 

<!--  JWT  -->

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->

<dependency>

    <groupId>io.jsonwebtoken</groupId>

    <artifactId>jjwt</artifactId>

            <version>0.9.1</version>

</dependency>


서버 프로그램이 Spring 을 기반으로 하고 있어 Spring Security 설정에 JWT 처리를 위한 커스텀 필터를 구현하여 추가하였다. (커스텀 필터는 클라이언트에서 헤더에 JWT 토큰을 가지고 오는 경우 이를 검증하고 인증 정보를 보안 컨텍스트에 추가하는 역할을 수행한다.)

package architecture.community.security.spring.authentication.jwt;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;

public class JwtTokenProvider {

	private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

	private static final String AUTHORITIES_KEY = "auth";

	/**
	 * JWT Token expire time.
	 */
	static final long EXPIRATIONTIME = 864_000_000; // 10 days

	/**
	 * Secret Key string
	 */
	static final String SECRET = "ThisIsASecret";

	/**
	 * JWT Token prefix
	 */
	static final String TOKEN_PREFIX = "Bearer";

	/**
	 * Header key for JWT Token
	 */
	static final String HEADER_STRING = "Authorization";

	public String createToken(Authentication authentication) {
		String authorities = authentication.getAuthorities().stream().map(authority -> authority.getAuthority())
				.collect(Collectors.joining(","));
		ZonedDateTime now = ZonedDateTime.now();
		ZonedDateTime expirationDateTime = now.plus(EXPIRATIONTIME, ChronoUnit.MILLIS);
		Date issueDate = Date.from(now.toInstant());
		Date expirationDate = Date.from(expirationDateTime.toInstant());
		return Jwts.builder().setSubject(authentication.getName()).claim(AUTHORITIES_KEY, authorities)
				.signWith(SignatureAlgorithm.HS512, SECRET).setIssuedAt(issueDate).setExpiration(expirationDate)
				.compact();
	}

	public Authentication getAuthentication(String token, UserDetailsService userDetailsService, boolean refresh) {
		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
		UserDetails details = userDetailsService.loadUserByUsername(claims.getSubject());
		return new UsernamePasswordAuthenticationToken(details, "", refresh ? details.getAuthorities() : authorities);
	}

	public Authentication getAuthentication(String token) {

		Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
		Collection authorities = Arrays
				.asList(claims.get(AUTHORITIES_KEY).toString().split(",")).stream()
				.map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());

		User principal = new User(claims.getSubject(), "", authorities);

		return new UsernamePasswordAuthenticationToken(principal, "", authorities);

	}

	public boolean validateToken(String authToken) {
		try {
			Jwts.parser().setSigningKey(SECRET).parseClaimsJws(authToken);
			return true;
		} catch (SignatureException e) {
			logger.info("Invalid JWT signature: " + e.getMessage());
			logger.debug("Exception " + e.getMessage(), e);
		} catch (MalformedJwtException e) {
			logger.error("Invalid JWT token: {}", e.getMessage());
		} catch (ExpiredJwtException e) {
			logger.error("JWT token is expired: {}", e.getMessage());
		} catch (UnsupportedJwtException e) {
			logger.error("JWT token is unsupported: {}", e.getMessage());
		} catch (IllegalArgumentException e) {
			logger.error("JWT claims string is empty: {}", e.getMessage());
		}
		return false;
	}
}

(JwtTokenProvider 사용하여 Jwt 토큰을 검증하고 userDetailsService 을 이용하여 보안 컨텍스트에 인증 객체를 설정한다.)
package architecture.community.security.spring.authentication.jwt;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;
import org.springframework.web.filter.GenericFilterBean;

import architecture.ee.util.StringUtils;
import io.jsonwebtoken.ExpiredJwtException;

public class JWTFilter extends GenericFilterBean {

	private Logger logger = LoggerFactory.getLogger(getClass());

	private final JwtTokenProvider jwtTokenProvider;

	@Autowired(required = false)
	@Qualifier("userDetailsService")
	private UserDetailsService userDetailsService;

	public JWTFilter(JwtTokenProvider jwtTokenProvider) {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
		this.jwtTokenProvider = jwtTokenProvider;
	}

	@Override
	public void afterPropertiesSet() {
		Assert.notNull(jwtTokenProvider, "JwtTokenProvider cannot be null");
	}

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		String header = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.isNullOrEmpty(header)) {
			chain.doFilter(request, response);
			return;
		}

		try {
			String jwt = this.resolveToken(request);
			if (!StringUtils.isNullOrEmpty(jwt)) {
				logger.debug("jwt token : {}", jwt);
				if (this.jwtTokenProvider.validateToken(jwt)) {
					Authentication authentication;
					if (userDetailsService != null) {
						authentication = this.jwtTokenProvider.getAuthentication(jwt, userDetailsService, true);
					} else {
						authentication = this.jwtTokenProvider.getAuthentication(jwt);
					}
					SecurityContextHolder.getContext().setAuthentication(authentication);
				}
			}
		} catch (ExpiredJwtException eje) {
			logger.info("Security exception for user {} - {}", eje.getClaims().getSubject(), eje.getMessage());
			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
			logger.debug("Exception " + eje.getMessage(), eje);
			return;
		}

		chain.doFilter(request, response);
		this.resetAuthenticationAfterRequest();
	}

	private void resetAuthenticationAfterRequest() {
		logger.debug("reset authentication as null.");
		SecurityContextHolder.getContext().setAuthentication(null);
	}

	private String resolveToken(HttpServletRequest request) {
		String bearerToken = request.getHeader(JwtTokenProvider.HEADER_STRING);
		if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtTokenProvider.TOKEN_PREFIX)) {
			String jwt = bearerToken.substring(7, bearerToken.length());
			return jwt;
		}
		return null;
	}
}  
  
필터를 정의하고 사용자 정의 필터를 추가해준다.

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  <custom-filter before="BASIC_AUTH_FILTER" ref="jwtFilter" />


  ...


</http>


<beans:bean id="jwtTokenProvider" class="architecture.community.security.spring.authentication.jwt.JwtTokenProvider"></beans:bean>

<beans:bean id="jwtFilter" class="architecture.community.security.spring.authentication.jwt.JWTFilter">

  <beans:constructor-arg ref="jwtTokenProvider" />

</beans:bean>



③ CORS 지원
주의할 것은 보안상 이유에서 웹 브라우져는 스크립트에서 웹페이지가 로드된 원 도메인(IP)이 아닌 타 도메인(IP) 자원을 호출하는 것을 금지하고 있다. 

CORS(Cross-origin resource sharing) 는 대부분의 웹 브라우져들이 구현하고 있는 W3C 표준이며 이를 통하여 다른 도메인의 자원을 호출 할 수 있게 할 수 있다. Spring Security 는 필터 기반의 CORS 를 지원하고 있어 아래와 같이 손쉽게 처리가 가능하다. 

<http auto-config="true" use-expressions="true" disable-url-rewriting="true">

  <cors configuration-source-ref="corsSource"/> 

  <headers>

    <frame-options policy="SAMEORIGIN" />

  </headers>

  <intercept-url pattern="/*" access="permitAll"/>

<intercept-url pattern="/error/*" access="permitAll"/>

<intercept-url pattern="/data/**" access="permitAll"/>

<intercept-url pattern="/display/**" access="permitAll"/>

<intercept-url pattern="/accounts/**" access="permitAll"/>

<intercept-url pattern="/secure/studio/**" access="hasRole('ROLE_ADMINISTRATOR') or hasRole('ROLE_SYSTEM') or hasRole('ROLE_DEVELOPER')" />

<intercept-url pattern="/secure/data/**" access="hasRole('ROLE_USER') or hasRole('ROLE_SYSTEM')" /> 

  <!-- Form Login Page Setting -->

  <form-login login-page="/accounts/login"

username-parameter="username" 

password-parameter="password"

login-processing-url="/accounts/auth/login_check"

authentication-success-handler-ref="authenticationSuccessHandler"

authentication-failure-handler-ref="authenticationFailureHandler" />

  <http-basic />

  ...


</http>


<beans:bean id="corsSource" class="org.springframework.web.cors.UrlBasedCorsConfigurationSource">

  <beans:property name="corsConfigurations">

    <util:map>

      <beans:entry key="/**">

      <beans:bean class="org.springframework.web.cors.CorsConfiguration">

      <beans:property name="allowCredentials" value="true"/>

      <beans:property name="allowedHeaders">

      <beans:list>

        <beans:value>Authorization</beans:value>

        <beans:value>Content-Type</beans:value>

        <beans:value>responseType</beans:value>

        <beans:value>encoding</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="allowedMethods">

        <beans:list>

        <beans:value>POST</beans:value>

        <beans:value>GET</beans:value>

        <beans:value>PUT</beans:value>

        <beans:value>DELETE</beans:value>

        <beans:value>OPTIONS</beans:value>

        </beans:list>

      </beans:property>

      <beans:property name="allowedOrigins" value="*" />

      <beans:property name="exposedHeaders">

      <beans:list>

        <beans:value>Location</beans:value>

        <beans:value>Content-Disposition</beans:value>

      </beans:list>

      </beans:property>

      <beans:property name="maxAge" value="86400" /></beans:bean>

    </beans:entry>

    </util:map>

  </beans:property>

</beans:bean>


JWT 기반 인증 및 검증

아이디/비밀번호 기반의 인증에서 JWT 토튼을 사용하려면 인증이 성공하면 클라이언트에 토큰 값을 응답해주어야 한다. 아래는 스프링 컨트롤러에 간단하게 로그인 인증후 Jwt 토큰 값을 리턴하는 예이다.

LoginRequest.java

@RequestMapping(value = "/signin.json", method = { RequestMethod.POST})

public ResponseEntity<JwtResponse> authenticateUser(@RequestBody LoginRequest loginRequest) { 

Authentication authentication = authenticationManager.authenticate(

new UsernamePasswordAuthenticationToken(

loginRequest.getUsername(), 

loginRequest.getPassword()));

SecurityContextHolder.getContext().setAuthentication(authentication);

String jwt = jwtTokenProvider.createToken(authentication); 

CommuintyUserDetails details = SecurityHelper.getUserDetails(authentication);

return ResponseEntity.ok( 

            new JwtResponse(

                jwt

                details.getUser(), 

                getRoles(details.getAuthorities())));


웹 프로그램은 아래와 같이 인증후 획득한 토큰값을 저장하고 서버와 통신이 필요한 경우 헤더에 토큰 값을 포함하여 전달하게 된다. 서버 통신은 axois 를 사용하여 구현하였다. 참고로 코드 구현에 있어 UI 부분은 kendoui 를 사용하였다. ( kendoui 를 사용하려면 개발자라이선스가 필요함)

var renderTo = $('form[name=form-signin]');
var observable = new kendo.observable({
    username : null,
    password : null
});

var validator = renderTo.kendoValidator({
  errorTemplate: "

#=message#

" }).data("kendoValidator");; kendo.bind( renderTo , observable ); renderTo.submit(function(e) { e.preventDefault(); if( validator.validate() ){ kendo.ui.progress(renderTo, true); axios.post(studio.services.getApiUrl('/data/accounts/signin.json'), JSON.stringify(observable), { headers: { "Content-Type": "application/json" }} ).then(response => { const data = response.data; studio.services.accounts.loginSuccess(studio.services.accounts.state, data); window.location.replace("index.html"); }).catch(function (error) { // handle error studio.ui.handleAxiosError(error); observable.set('password', null); }) .then(function () { // always executed kendo.ui.progress(renderTo, false); }); } });

index.js
 
 const user = JSON.parse(localStorage.getItem("user"));
 const initialState = user
  ? { status: { loggedIn: true }, user }
  : { status: { loggedIn: false }, user: null };
  
 function authHeader() {
  // return authorization header with jwt token
  let _user = JSON.parse(localStorage.getItem("user"));
  if (_user && _user.jwtToken) {
    return { Authorization: "Bearer " + _user.jwtToken };
  } else {
    return {};
  }
  }

 function loginSuccess(state, data) {
  if (data.jwtToken) {
    // store user details and jwt token in local storage to keep user logged in between page refreshe
    localStorage.setItem("user", JSON.stringify(data));
  }
  state.loggedIn = true;
  state.user = data;
}

서버 호출이 필요한 경우 아래와 같은 방식으로 헤더에 jwt 포함하여 전송한다. 

sttings-locale.html 
const headers = {
  Accept: "application/json",
  "Content-Type": "application/json"
};
Object.assign(headers, studio.services.accounts.authHeader());

...

axios({
  url: studio.services.getApiUrl('/data/secure/mgmt/locale/save-or-update.json'),
  method: "post",
  data: JSON.stringify({ locale: $this.locale, timeZone: $this.timezone }),
  headers: headers
}).then(response => {
  let data = response.data;
  dialog.close();
}).catch(function (error) {
  studio.ui.handleAxiosError(error);
}).then(function () {
  // always executed
  kendo.ui.progress($('.k-dialog'), false);
});
                      

JWT 기반 인증 적용 후  경험한 문제들

  • spring security 에서 중복로그인 방지를 설정한 경우 중복 로그인 오류로 인하여 정상적인 응답을 받지 못하는 경우가 다수 발생했다. 
  • 인증된 사용자에게만 보여지는 이미지와 같은 바이너리 형식을 처리하는 경우에 어려움이 있었다. 

참고자료

Securing Spring Boot with JWTs
JWT (JSON Web Token) 이해와 활용
CORS support in Spring Framework

2017년 12월 1일

스프링 시큐리티(Spring Security) - 도메인 객체 보안 (Domain Object Security ACL) 활용하기

대부분의  스프링 시큐리티를 사용하는 웹 응용프로그램들은  누가(Who)  어떤 URL 또는 어떤 메소드(Where)에 대한 호출에 대한 접근을 제안하는 방법으로 스프링 시큐리티를 사용하고 있다.


이런 접근 방식에서는 롤(ROLE)를 정의하고 사용자에게 롤(ROLE) 부여하고 특정 URL 또는 특정 클래스 함수에 대하여 ROLE 에 따른 접근제어를 하는 것으로 권한 관리를 구현하게 된다.

그러나 웹 프로그램들은 우리가 생각하는 이상으로 복잡하기 때문에 누가(Who) , 어디를 (Where) 뿐 아니라 무엇을 (What) 포함하여 권한을 설정할 수 있어야 한다.

예를 들어 아래와 게시판 프로그램을 디자인 한다고 가정해보자.

  • REQ1: Q&A게시판(B1)은 누구나(U1) 읽기(P1) / 쓰기(P2)가 가능하고 , 
  • REQ2: 자료실 게시판(B2)은 누구나(U1) 읽기(P1)는 가능하지만 관리자(U2)만 쓰기(P2)가 가능하고,  
  • REQ3: 고객지원 게시판(B3) 은 지정된 특정 사용자(U3) 만 읽기(P1) / 쓰기(P2)가 가능해야 한다.

누가(Who) , 어디를 (Where) 정보를 사용하여 권한을 결정하는 방식에서는 위의 기능을 구현하게 위하여 각기 다른 URL 또는 함수들 만들고 REQ3 요구사항을 위하여 추가로 권한 검사를 위하여 응용 프로그램 레벨에서 다시 권한결정을 위한  하드코딩이 필요하게 된다.

(Who) , 어디를 (Where) 뿐 아니라 무엇을 (What) 포함하여 권한을 결정하는 접근 방식에서는 게시판 객체 (What)에대한 권한을 설정할 수 있기 때문에 응용 프로그램 레벨에서의 하드 코딩 없이 구현이 가능하다. 이를 위하여 스프링 시큐리티는 도메인 객체 보안 (ACL) 서비스를 제공하고 있다.

스프링 시큐리티 ACL 사용하기 

도메인 객체 보안 (ACL) 서비스는   spring-security-acl-xxx.jar  라이브러리를 통하여 제공된다.  Spring 기반 웹 프로그램 개발 Part 2 - SpringSecurity 사용하기 와 같이 pom.xml 파일을 기술하였다면 관련 라이브러리는 포함되어 있다. (spring-security-taglibs 가 spring-security-acl 에 대한 의존성을 가지고 있기 때문에 자동으로 포함된다.)


    org.springframework.security
    spring-security-core
    ${project.dependency.spring-security.version}
    compile
  

    org.springframework.security
    spring-security-web
    ${project.dependency.spring-security.version}
    compile


    org.springframework.security
    spring-security-config
    ${project.dependency.spring-security.version}


    org.springframework.security
    spring-security-test
    ${project.dependency.spring-security.version}
    test  



또는 직접 다음과 같이 spring-security-acl 라이브러리를 추가해도 된다.


    org.springframework.security
    spring-security-core
    4.1.3.RELEASE
    compile
  

    org.springframework.security
    spring-security-web
    4.1.3.RELEASE
    compile


    org.springframework.security
    spring-security-config
    4.1.3.RELEASE


    org.springframework.security
    spring-security-acl
    4.1.3.RELEASE
    test  


다음으로 도메인 객체 보안 (ACL) 서비스를 사용하려면 ACL 정보를 어딘가에 저장해야 한다. 이를 위하여 ACL 데이터를 저장할 데이터베이스와 관련 테이블 생성이 필요하다. 테이블 생성은 spring-security-acl-xxx.jar 에 포함된 sql 파일을 이용하면 된다. ( mysql, oracle, postgres, SqlServer 에 대항하는 스크립트가 별도로 지원)



다음은 스크립트를 통하여 생성되는 테이블에 대한 다이어그램이다.



  • ACL_SID : 롤, 사용자에 대한 키 정보가 저장되는 테이블이다. ID 는 유니크한 숫자값, PRINCIPAL 는 롤의 경우는 0 사용자의 경우는 1 , SID 는 사용자 아이디 또는 롤 정보를 의미한다. 
  • ACL_CLASS : 도메인 객체 클래스에 대한 정보가 저장되는 테이블이다. ID 는 유니크한 숫자값, CLASS 는 클래스 이름을 의미한다. 
  • ACL_OBJECT_IDENTITY : 도메인 객체 인스턴스 정보가 저장되는 테이블이다. ID 는 유니크한 숫자값, OBJECT_ID_CLASS 는 클래스에 해당하는 ACL_CLASS.ID, OWNER_SID 는 생성자를 나타내는 ACL_SID.ID를 의미한다. 
  • ACL_ENTRY : 접근 권한 데이터가 저장되는 테이블이다. ID 는 유니크한 숫자값, OBJECT_ID_CLASS 는 클래스에 해당하는 ACL_CLASS.ID, OBJECT_ID_IDENTITY 는 객체 인스턴스를 나타내는 ACL_OBJECT_IDENTITY.ID , SID 는 권한이 부여된 대상을 의미하는 ACL_SID.ID, MASK 는 권한을 의미하는 마스크 값이다. 기본적으로 부여되는 모든 권한들은 유니크한 마스크 값을 갖는다.

예를 들어 dhson 사용자에게 ID 값이 9 인 architecture.community.board.Board 객체에 마스크 값이 1에 해당하는 READ 권한을 부여한다고 하면 다음과 같은 데이터들이 저장된다.

ACL_SID 
ID : 1, PRINCIPAL: 1, SID:dhson

ACL_CLASS
ID: 1, CLASS: architecture.community.board.Board

ACL_OBJECT_IDENTITY
ID: 1, OBJECT_ID_CLASS: 1, OBJECT_ID_IDENTITY: 9

ACL_ENTRY
ID: 1, ACL_OBJECT_IDENTITY: 1, SID: 1, MASH : 1

이제 아래와 같이 도메인 객체 보안 (ACL) 서비스 사용을 위하여 객체들을 **-context.xml 파일에 기술한다.

permissionSubsystemContext.xml


 Spring Security Domain Object ACL 설정

 
        
        
  
 
 
 
  
 
 
 
  
  
  
  
  
 
 
 
  
  
  
  
 
 
 
  
  
 
 
  
   
  
  
 
 
  
 
  
  
 
  
     
 



이제 자바 클래스에서 communityAclService 를 직접 사용하여 권한을 확인 하거나 @PreAuthorize 어노테이션을 사용하여 손쉽게 권한 제어가 가능하다.


  @PreAuthorize("hasPermission(#message, write) or hasPermission(#message, admin)")
  public void editMessage(Message message) { ... }


또는 아래와 같은 어노테이션도 가능하다.

  @PreAuthorize("hasPermission(#boardId, 'architecture.community.board.Board', 'READ')")
  @RequestMapping(value = {"/{boardId:[\\p{Digit}]+}/list"}, method = { RequestMethod.POST, RequestMethod.GET } )
  public String displayThreadList ( @PathVariable Long boardId,  HttpServletRequest request, HttpServletResponse response,  Model model) throws BoardNotFoundException{  
    / ***
  }


2017년 6월 10일

Spring Security - Login 시 사용자 정의 입력 값 전달하기

SpringSecurity 기반의 인증을 사용하는 환경에서 username 과 password 이외의 값을 추가로 전달하여 사용자를 인증을 할 수 없을까? (예를들어 회사번호, 아이디, 비밀번호를 전달받아 인증을 하는 경우 )

환경

Spring 4.3.1.RELEASE
Spring Security 4.1.2.RELEASE

Stack overflow 및 몇가지 자료를 참고하면 아래와 같이 구현을 한면 되다고 한다.

How to pass an additional parameter with spring security login page
  1. UsernamePasswordAuthenticationFilter 를 확장하여 사용자 정의 필터를 구현.
  2. 사용자 정의 UserDetailsService 구현.
  3. <http/> 설정에 사용자 정의 필터를 추가
아쉽게도  SpringSecurity 4.1 부터는 내부 구현 로직을 변경으로 UsernamePasswordAuthenticationFilter 를 위와 같은 방식으로 사용자 정의 필터로 변경할 수 없다.

SpringSecurity 4.1 이상의 환경에서는 필터를 수정하지 않고 RequestContextFilter 를 사용하여 사용자 정의 AuthenticationProvider 구현체에서  RequestContextHolder 를 사용하여 로그인시에 전달된 추가적인 파라메터 값을 꺼내어 원하는 방법으로 인증을 구현하면 된다.

RequestContextFilter 필터는 web.xml 에 필터 설정을 추가하여 사용할 수 있다.

WEB-INF/web.xml

 
 
   requestContextFilter
   org.springframework.web.filter.RequestContextFilter
 
 
   requestContextFilter
   /*
      
 
 
 
  springSecurityFilterChain
  org.springframework.web.filter.DelegatingFilterProxy
 
 
  springSecurityFilterChain
  /*
 


또는 Spring Security 설정 컨텍스트 ( 문서에서는 WEB-INF/context-config/securitySubsystemContext.xml 사용함 ) 에 아래와 같이 필터를 추가한다.

WEB-INF/context-config/securitySubsystemContext.xml
     
            
    
    
    


사용자 정의 인증 구현에서는 아래와 같이 RequestContextHolder 클래스를 사용하여 클라이언트에서 전달된 파라메터 값을 접근할 수 있다.

    ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();  
    if( attr!=null && attr.getRequest() != null ) {
        long companyId = ParamUtils.getLongParameter(attr.getRequest(), "companyId", 0L);
        return super.retrieveUser(companyId, username, authentication);
    }else{  
        return super.retrieveUser(username, authentication);
    }



참고자료
Spring 기반 웹 프로그램 개발 Part 2 - SpringSecurity 사용하기

2017년 3월 16일

Spring 기반 웹 프로그램 개발 Part 2 - SpringSecurity 사용하기

SpringSecurity 기술을 사용하면
  •  인증, ROLE 기반 접근제어, 
  •  여러 알고리즘을 지원하는 비밀 번호 암호화 및 확인 
  •  어노테이션을 이용한 함수 수준의 접근 제어 
 등의 기능들을 쉽게 구현할 수 있다.

개인적으로 SpringSecurity 을 사용하는 이유는
  • 첫번째 OWASP TOP 10 취약점 중 최소한 A2, A4, A6, A7, A8 에 해당하는 보안 이슈 해결에 도움을 준다는 점이다.  
  • 두번째 레어어화 된 보안 아키텍처를 응용프로그램에 도입하므로써  개발 단계 부터 안전한 응용프로그램 구현이 가능해진다는 점이다.  왜 우리가 Struts2, Spring MVC 와 같은 MVC 프레임워크를 사용하는 가를 생각해보면 보안 프레임워크의 도입의 이유도 설명이 될 것 같다.  

환경 설정
개인적으로 Maven 기반의 개발환경을 선호하기 때문에 여기에서는 아래의 Maven Dependency 을 프로젝트에 추가한다.



    org.springframework.security
    spring-security-core
    ${project.dependency.spring-security.version}
    compile
  

    org.springframework.security
    spring-security-web
    ${project.dependency.spring-security.version}
    compile


    org.springframework.security
    spring-security-config
    ${project.dependency.spring-security.version}


    org.springframework.security
    spring-security-test
    ${project.dependency.spring-security.version}
    test  


SpringSecurity 사용하기 
SpringSecurity 기반의 인증 및 접근 제어를 위하여 다음 인터페이스들을 구현한다.

  • AuthenticationProvider : 사용자 인증을 수행하는 인터페이스 클래스로 자신만의 비밀번호 기반의 인증을 위해서는 커스텀 클래스 구현이 필요하다.
  • AuthenticationSuccessHandler : 인증에 성공한 경우 다음 행위를 제어하기 위한 인터페이스 클래스. API형식의 인증 서비스 구현을 위해서는 커스텀 구현 클래스가 필요하다.
  • UserDetailsService : 사용자 정보를 로드하기 위한 인터페이스 클래스로 자신의 사용자 정보를 사용하여 인증 및 권한을 관리하고자 하는 경우는 커스텀 구현 클래스가 필요하다.

SpringSecurity 설정 
환경에 따라 다른 설정을 통하여 웹 프로그램을 제어하는 방법을 선호하기 때문에 XML 방식을 선호한다. 어노테이션은 부분적으로만 사용한다.

SpringSecurity 커스터 구현과 설정

AuthenticationProvider & AuthenticationSuccessHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package architecture.community.spring.security.authentication;
 
import javax.inject.Inject;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
 
import com.google.common.eventbus.EventBus;
 
import architecture.community.i18n.CommunityLogLocalizer;
import architecture.community.spring.security.userdetails.CommuintyUserDetails;
import architecture.community.user.UserManager;
import architecture.community.user.event.UserActivityEvent;
 
public class CommunityAuthenticationProvider extends DaoAuthenticationProvider {
    
    private Logger logger = LoggerFactory.getLogger(getClass());
    
    @Inject
    @Qualifier("userManager")
    private UserManager userManager;    
    
    @Autowired(required = false)
    @Qualifier("eventBus")
    private EventBus eventBus;
    
    
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
 
        if (authentication.getCredentials() == null)
            throw new BadCredentialsException(CommunityLogLocalizer.getMessage("010101"));        
        super.additionalAuthenticationChecks(userDetails, authentication);
        try {
            CommuintyUserDetails user = (CommuintyUserDetails) userDetails;
            if(eventBus!=null){
                eventBus.post(new UserActivityEvent(this, user.getUser(), UserActivityEvent.ACTIVITY.SIGNIN ));
            }
        } catch (Exception e) {
            logger.error(CommunityLogLocalizer.getMessage("010102"), e);
            throw new BadCredentialsException( messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials""Bad credentials"));
        }        
    }    
}
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package architecture.community.spring.security.authentication;
 
import java.io.IOException;
import java.util.Map;
 
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.ui.ModelMap;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
 
import architecture.community.web.model.json.Result;
import architecture.community.web.util.ServletUtils;
import architecture.ee.util.StringUtils;
 
public class CommunityAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
 
    private Logger logger = LoggerFactory.getLogger(getClass());
    
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
 
        if (ServletUtils.isAcceptJson(request)) {
            Result result = Result.newResult();
            result.getData().put("success"true);
            result.getData().put("returnUrl", ServletUtils.getReturnUrl(request, response));
            String referer = request.getHeader("Referer");
            if (StringUtils.isNullOrEmpty(referer))
                result.getData().put("referer", referer);
            Map<String, Object> model = new ModelMap();
            model.put("item", result);
            
            MappingJackson2JsonView view = new MappingJackson2JsonView();
            view.setExtractValueFromSingleKeyModel(true);
            view.setModelKey("item");
            try {
                createJsonView().render(model, request, response);
            } catch (Exception e) {}        
            return;
        }
        super.onAuthenticationSuccess(request, response, authentication);
    }
 
    protected View createJsonView(){
        MappingJackson2JsonView view = new MappingJackson2JsonView();
        view.setExtractValueFromSingleKeyModel(true);
        view.setModelKey("item");
        return view;
    }
 
}
 
cs

UserDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package architecture.community.spring.security.userdetails;
 
import java.util.Collections;
import java.util.List;
 
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
 
import com.fasterxml.jackson.annotation.JsonIgnore;
 
/**
 * 
 * @author donghyuck
 *
 */
public class CommuintyUserDetails extends User {
 
    @JsonIgnore
    private final architecture.community.user.User communityUser;
 
    public CommuintyUserDetails(architecture.community.user.User communityUser) {
        super(communityUser.getUsername(), communityUser.getPassword(), communityUser.isEnabled(), truetruetrue, AuthorityUtils.NO_AUTHORITIES);
        this.communityUser = communityUser;
    }
 
    public CommuintyUserDetails(architecture.community.user.User communityUser, List<GrantedAuthority> authorities) {
        super(communityUser.getUsername(), communityUser.getPassword(), communityUser.isEnabled(), truetruetrue, authorities);
        this.communityUser = communityUser;
    }
 
    public boolean isAnonymous() {
        return communityUser.isAnonymous();
    }
 
    public architecture.community.user.User getUser() {
        return communityUser;
    }
 
    public long getUserId() {
        return communityUser.getUserId();
    }
 
    public long getCreationDate() {
        return communityUser.getCreationDate() != null ? communityUser.getCreationDate().getTime() : -1L;
    }
 
}
 
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package architecture.community.spring.security.userdetails;
 
import java.util.ArrayList;
import java.util.List;
 
import javax.inject.Inject;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
 
import architecture.community.user.Role;
import architecture.community.user.RoleManager;
import architecture.community.user.User;
import architecture.community.user.UserManager;
import architecture.community.user.UserNotFoundException;
import architecture.community.util.CommunityConstants;
import architecture.ee.service.ConfigService;
import architecture.ee.util.StringUtils;
public class CommunityUserDetailsService implements UserDetailsService {
 
    private Logger logger = LoggerFactory.getLogger(getClass());
    
    @Inject
    @Qualifier("userManager")
    private UserManager userManager;    
    
    @Inject
    @Qualifier("roleManager")
    private RoleManager roleManager;    
    
    
    @Inject
    @Qualifier("configService")
    private ConfigService configService;    
    
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 사용자 환경에 적절하게 구현..
        try {            
            User user = userManager.getUser(username);            
            CommuintyUserDetails details = new CommuintyUserDetails(user, getFinalUserAuthority(user));            
            return details ;            
        } catch (UserNotFoundException e) {
            throw new UsernameNotFoundException("User not found.", e);    
        }
    }
 
    protected List<GrantedAuthority> getFinalUserAuthority(User user) {            
         // 사용자 환경에 적절하게 구현..    
        String authority = configService.getLocalProperty(CommunityConstants.SECURITY_AUTHENTICATION_AUTHORITY_PROP_NAME);
        List<String> roles = new ArrayList<String>();        
        if(! StringUtils.isNullOrEmpty( authority ))
        {
            authority = authority.trim();
            if (!roles.contains(authority)) {
                roles.add(authority);
            }
        }
        for(Role role : roleManager.getFinalUserRoles(user.getUserId())){
            roles.add(role.getName());
        }
        return AuthorityUtils.createAuthorityList(StringUtils.toStringArray(roles));
    }
    
}
 
cs

Context.xml(스프링 설정 파일)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/security"
    xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:p="http://www.springframework.org/schema/p" xmlns:c="http://www.springframework.org/schema/c"
    xmlns:aop="http://www.springframework.org/schema/aop" xmlns:jee="http://www.springframework.org/schema/jee"
    xmlns:tx="http://www.springframework.org/schema/tx" xmlns:lang="http://www.springframework.org/schema/lang"
    xmlns:util="http://www.springframework.org/schema/util"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd
       http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee.xsd
       http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd
       http://www.springframework.org/schema/lang http://www.springframework.org/schema/lang/spring-lang.xsd
       http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd 
       http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd">
    <beans:description><![CDATA[
       Spring Security 설정  
    ]]></beans:description>
    <global-method-security secured-annotations="enabled" pre-post-annotations="enabled" />
    <http auto-config="true" use-expressions="true" disable-url-rewriting="true">        
        <intercept-url pattern="/*"                        access="permitAll"/>
        <intercept-url pattern="/data/*"                access="permitAll"/> 
        <intercept-url pattern="/secure/data/**"         access="hasRole('ROLE_USER')" />    
        <!-- 로그인 페이지 지정 -->
        <form-login 
            login-page="/accounts/login" 
            username-parameter="username" 
            password-parameter="password"
            login-processing-url="/accounts/auth/login_check"
            authentication-success-handler-ref="authenticationSuccessHandler"
            authentication-failure-url="/error/401" />
        <!-- 로그아웃 설정  -->
        <logout invalidate-session="true" logout-url="/accounts/logout" logout-success-url="/" delete-cookies="JSESSIONID" />
        <!-- CSRF ATTACK  -->
        <csrf disabled="true" />
        <anonymous enabled="true" username="ANONYMOUS" />      
        <!-- 중복 로그인 방지 설정 -->
        <session-management session-fixation-protection="newSession" >
            <concurrency-control max-sessions="1" expired-url="/error/login_duplicate"/>
        </session-management>        
        <!-- 접근 불허시 보여줄 페이지 설정 -->         
        <access-denied-handler error-page="/error/unauthorized" />  
    </http>
    <authentication-manager id="authenticationManager">
        <authentication-provider ref="authenticationProvider"/>
    </authentication-manager>    
    <beans:bean id="authenticationProvider" 
        class="architecture.community.spring.security.authentication.CommunityAuthenticationProvider" 
        p:passwordEncoder-ref="passwordEncoder" p:userDetailsService-ref="userDetailsService" />    
    <beans:bean id="authenticationSuccessHandler" 
        class="architecture.community.spring.security.authentication.CommunityAuthenticationSuccessHandler"/>            
    <beans:bean id="userDetailsService" class="architecture.community.spring.security.userdetails.CommunityUserDetailsService" />    
    <beans:bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></beans:bean>    
</beans:beans> 
cs



소스 자료  
Community 1.0.0-BETA


참고 자료 

OWASP top ten attacks and Spring Security
Spring Security Tutorial
Securing a Web Application