본문 바로가기
Programming/>> Spring

[Spring] Spring기반 웹 사이트 템플릿 만들기 - 3. Spring Security 설정

by 니키ᕕ( ᐛ )ᕗ 2017. 9. 2.

Spring Security 설정을 하는데 이번에 내가 포스팅 하는 설정은 전자정부프레임워크의 Spring Security에서 전자정부프레임워크를 빼서 커스터마이징 한 것이다. 전 회사에서 사용자 테이블이 두 개 였던지라 EGOV에서 지원하는 SQL String방식으로는 사용할 수가 없어서 소스 뜯어 고쳐가면서 했던 건데 그때 포스팅을 남기려다가 귀찮고 복잡해서 안했는데 이번에 다시 적용하면서 공부하고 기록을 남겨본다.

 

이번 기회에 공부한답시고 찾아보면서 알게 된건데 어차피 Spring Security 설정에 정해진 왕도는 없었다. 잘 짜여진게 있다면 그걸 따라서 정리하는 것도 나쁘지 않은듯.

 

일단 pom.xml부터.. 별거 없다 Spring security 버전은 4.2.3.

 

<properties>
	<springsecurity.version>4.2.3.RELEASE</springsecurity.version>
</properties>
<dependencies>
	....
	<!-- Spring Security -->
	<dependency>		
		<groupId>org.springframework.security</groupId>
		<artifactId>spring-security-web</artifactId>
		<version>${springsecurity.version}</version>
	</dependency>
	<dependency>
		<groupId>org.springframework.security</groupId>
		<artifactId>spring-security-config</artifactId>
		<version>${springsecurity.version}</version>
	</dependency>
	<dependency>
		<groupId>org.springframework.security</groupId>
		<artifactId>spring-security-taglibs</artifactId>
		<version>${springsecurity.version}</version>
	</dependency>
	<!--/ Spring Security -->
	...
<dependencies>

 

 

아래는 web.xml 설정이다. 필터체인을 커스터마이징하면 이 부분도 바뀌는데 일단은 정석대로 가자.

 

<!-- Spring Security -->
<filter>
	<filter-name>springSecurityFilterChain</filter-name>
	<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
	<filter-name>springSecurityFilterChain</filter-name>
	<url-pattern>/*</url-pattern>
</filter-mapping>
<!--/ Spring Security -->

 

본 포스팅을 구현하기 위해 사용된 DB 스키마와 데이터.

CREATE TABLE IF NOT EXISTS TB_USER (
	USER_NO INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 1) NOT NULL, 
	USER_ID varchar(30) NOT NULL,
	PASSWORD varchar(50) NOT NULL,
	ROLE_ID VARCHAR(30) NOT NULL,
	USER_NAME varchar(25) NOT NULL,
	EMAIL varchar(200),
	POINT INTEGER DEFAULT 0,
	REG_DATE timestamp DEFAULT CURRENT_TIMESTAMP,
	LAST_DATE timestamp DEFAULT CURRENT_TIMESTAMP,
	GENDER char(1),
	CONSTRAINT TB_USER_PK PRIMARY KEY (USER_ID)
);

CREATE TABLE IF NOT EXISTS SECURITY_ROLE (
	ROLE_NO INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 1) NOT NULL, 
	ROLE_ID VARCHAR(30) NOT NULL,
	ROLE_NAME VARCHAR(30) NOT NULL,
	USE_YN CHAR(1),
	EDITABLE CHAR(1),
	CONSTRAINT SECURITY_ROLE_PK PRIMARY KEY (ROLE_ID)
);

CREATE TABLE IF NOT EXISTS SECURITY_ROLE_HIERARCHY (
	PARENT_ROLE_ID VARCHAR(30),
	ROLE_ID VARCHAR(30) NOT NULL,
	ROLE_NAME VARCHAR(30) NOT NULL,
	CONSTRAINT SECURITY_ROLE_HIERARCHY_PK PRIMARY KEY (ROLE_ID),
	CONSTRAINT SECURITY_ROLE_HIERARCHY_FK FOREIGN KEY (PARENT_ROLE_ID) REFERENCES SECURITY_ROLE_HIERARCHY (ROLE_ID)
);
INSERT INTO TB_USER(USER_ID, PASSWORD, ROLE_ID, USER_NAME, EMAIL, POINT, GENDER) VALUES('admin', 'admin', 'ROLE_ADMIN', '운영자', 'admin@admin.com', 0, 'F');
INSERT INTO TB_USER(USER_ID, PASSWORD, ROLE_ID, USER_NAME, EMAIL, POINT, GENDER) VALUES('manager', 'manager', 'ROLE_MANAGER', '관리자', 'manager@manager.com', 0,'M');
INSERT INTO TB_USER(USER_ID, PASSWORD, ROLE_ID, USER_NAME, EMAIL, POINT, GENDER) VALUES('user', 'user', 'ROLE_USER', '일반사용자', 'user@user.com', 0, 'M');

INSERT INTO SECURITY_ROLE(ROLE_ID, ROLE_NAME, USE_YN, EDITABLE) VALUES('ROLE_ADMIN', '운영자', 'Y', 'N');
INSERT INTO SECURITY_ROLE(ROLE_ID, ROLE_NAME, USE_YN, EDITABLE) VALUES('ROLE_MANAGER', '관리자', 'Y', 'Y');
INSERT INTO SECURITY_ROLE(ROLE_ID, ROLE_NAME, USE_YN, EDITABLE) VALUES('ROLE_USER', '일반사용자', 'Y', 'Y');

INSERT INTO SECURITY_ROLE_HIERARCHY(PARENT_ROLE_ID, ROLE_ID, ROLE_NAME) VALUES(NULL, 'ROLE_ADMIN', '운영자');
INSERT INTO SECURITY_ROLE_HIERARCHY(PARENT_ROLE_ID, ROLE_ID, ROLE_NAME) VALUES('ROLE_ADMIN', 'ROLE_MANAGER', '관리자');
INSERT INTO SECURITY_ROLE_HIERARCHY(PARENT_ROLE_ID, ROLE_ID, ROLE_NAME) VALUES('ROLE_MANAGER', 'ROLE_USER', '일반사용자');

 

SecurityConfig.java

1) @Configuration
2) @EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	3) 
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			4) .authorizeRequests()
				.antMatchers("/resources/**").permitAll()
				.antMatchers("/admin/**").hasRole("ADMIN")
				.antMatchers("/board/**").authenticated()
				.antMatchers("/auth/login", "/auth/login/*").permitAll()
				.antMatchers("/").permitAll()
				.and()
			5) .formLogin()
				.loginProcessingUrl("/auth/login/process")
				.loginPage("/auth/login")
				.failureUrl("/auth/login")
				.defaultSuccessUrl("/")
				.permitAll()
				.and()
			6) .logout()
				.logoutUrl("/logout")
				.logoutSuccessHandler(null)
				.invalidateHttpSession(true)
	}
}

1) Spring의 Context설정을 위한 Java Config Annotation 선언.

 

2) 해당 클래스가 Spring Security 설정에 관한 파일임을 선언, WebSecurityConfigurerAdapter를 상속.

 

3) WebSecurityConfigurerAdapter 에는 3가지 configure 메소드를 재정의 할 수 있다.

- configure(WebSecurity) : 리소스 무시, 디버그 모드 설정, 사용자 지정 방화벽 정의 구현을 통한 요청 거부 등의 전역보안을 설정하기 위한 메소드

- configure(HttpSecurity) : 리소스 수준에서의 웹 기반 보안 구성, 사용자와 권한을 다루는 설정을 위한 메소드

- configure(AuthenticationManagerBuilder) : 사용자 인증 정보에 대한 메카니즘을 설정하기 위한 메소드

 

4) Request URL 별 권한 설정

- antMatchers : Ant Pattern Style로 URL을 매핑

- permitAll : 해당 URL은 모든 경우에 접속 가능

- hasRole : 메소드 파라미터로 주어지는 권한을 가진 사용자의 경우에만 접속이 가능

- authenticated : 인증된 사용자만 접속 가능

* 이 경우에는 resource와 메인페이지, 로그인, 로그인 진행 URL 모든 경우에 접근 가능해야하므로 permitAll을 주었고 /admin 페이지는 ROLE_ADMIN인 경우에만, /board는 로그인한 사용자면 접근이 가능하도록 설정하였다

* hasRole의 권한은 기본적으로 'ROLE_'을 생략한 형태이다 DB내 권한이 ADMIN인 경우에는 적용이 안되고 ROLE_ADMIN이어야 한다.

 

 

5) form을 이용한 로그인일 경우에 formLogin을 사용, API 방식은 다르게 있는데 이건 나중에 수정해볼생각

- loginProcessingUrl : 로그인을 수행하는 URL. 로그인 폼에 들어가는 action의 주소

- loginPage : 로그인 페이지 ex) 인증되지 않은 사용자가 인증이 필요한 페이지로 접근하는 경우 로그인 폼이 있는 페이지로 이동

- failureUrl : 로그인 실패시 이동 페이지

- defaultSuccessUrl : 로그인 성공시 이동 페이지

 

6) 로그아웃 설정

- logoutUrl : 로그아웃 요청 URL

- logoutSuccessHandler : 로그아웃 성공시 부가적으로 기능을 추가하는 경우 사용

- invalidateHttpSession : 세션 만료 시킬 것인지 true / false

 

 

이어서 사용자 인증에 대한 설정을 한다

	@Override
    1) protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

	@Bean
    2) public DaoAuthenticationProvider authenticationProvider() {
	
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService());
//      authenticationProvider.setPasswordEncoder(passwordEncoder());
        return authenticationProvider;
    }
	
//	@Bean
//	3) public PasswordEncoder passwordEncoder() {
//		return new BCryptPasswordEncoder();
//	}
	
	@Bean 
	4) public UserDetailsService userDetailsService() {
		UserDetailsService userDetail = new CustomUserDetailsService();
		return userDetail;
	}

1) Spring Security가 authenticationProvider() 메소드를 통해 커스터마이징한 AuthenticationProvider를 적용할 수 있도록 설정.

 

2) DAO를 통해 DB로부터 사용자 인증 정보를 가져올 것이기 때문에 DaoAuthenticationProvider에 UserDetailService를 구현한 결과 값을 파라미터로 넘김 .

 

3) 비밀번호 암호화는 아직 고려대상이 아니므로 주석 처리.

 

4) UserDetailsService를 구현한 CustomUserDetailsService를 생성하여 리턴.

public class CustomUserDetailsService implements UserDetailsService {
	
	@Autowired
	1) SecurityService securityService;
	
	2) private RoleHierarchyImpl roleHierarchy;
	
	public CustomUserDetailsService() {
        this.roleHierarchy = new RoleHierarchyImpl();
    }

	@Override
	3) public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
		UserInfo user = securityService.getUserByUserId(userId);
		if(user == null) {
			throw new UsernameNotFoundException("존재하지 않는 사용자입니다.");
		}
		
		List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
		authorities.add(user);
		
		if (user.getAuthority() == null) {
			throw new UsernameNotFoundException("권한정보가 없는 사용자입니다.");
        }
		
		return new User(user.getUserId(), user.getPassword(), user.isEnabled(), true, true, true, buildRoleAuthorities(authorities));
	}

	/* RoleHierarchyImpl 에서 저장한 Role Hierarchy 정보가 저장된다. */
	4) private Collection<? extends GrantedAuthority> buildRoleAuthorities(List<GrantedAuthority> authorities) {
		roleHierarchy.setHierarchy(securityService.getRolesHierarchy());
		return roleHierarchy.getReachableGrantedAuthorities(authorities);
	}
}

4 - 1) DB로부터 인증정보를 가져오기 위해 Service 등록.

 

4 - 2) 사용자에게 계층적으로 권한을 부여하기 위해 구현된 클래스 등록. 4 - 4에서 부가 설명.

 

4 - 3) UserDetailsService의 핵심인 loadUserByUsername 메소드를 재정의한다. 이 메소드에서는 로그인 폼에서 전달받은 username이 DB에 존재하는지 확인하고 데이터를 가져와서 아이디, 비밀번호, 권한정보 등을 UserDetails 인터페이스를 구현한 User 클래스에 저장한다.

 

4 - 4) 이 부분은 전자정부프레임워크에서 차용한 부분으로 

ROLE_ADMIN > ROLE_MANAGE

ROLE_MANAGE > ROLE_USER

- 위 같은 형식으로 된 권한 계층 문자열을 받아 사용자에게 권한을 부여한다. ROLE_ADMIN의 사용자라면 ROLE_ADMIN, ROLE_MANAGE, ROLE_USER에 대한 권한도 가지게 되고 ROLE_MANAGE의 사용자라면 ROLE_MANAGE, ROLE_USER의 권한을 가지게 되는 방식이다. 

- 전자정부프레임워크에서는 RoleHierarchyImpl을 별도의 Bean으로 두고 Bean이 등록될 때만 DB로부터 계층 데이터를 가져오게 되어있는데 나의 경우에는 차후 admin 페이지에서 이를 바꾸게 할 거라 로그인 할 때마다 체크하는 방식을 택했다.

 

 

 

 

아래는 사용자 정보와 계층 정보 가져오는 부분이다. 사실 Spring Security 설정보다 Hibernate 사용하는게 더 어렵고 오래 걸렸다...

 

SecurityDAO.java

@Repository
@SuppressWarnings({"rawtypes", "unchecked"})
public class SecurityDAO extends AbstractDAO {
	
	public UserInfo getUserByUserId(String username) {
		return getSession().get(UserInfo.class, username);
	}

	public List<RoleHierarchy> getRolesHierarchy() {
		List<RoleHierarchy> rolesHierarchy = new ArrayList<RoleHierarchy>();
		try {
			Query query = getSession().createQuery("From RoleHierarchy");
			rolesHierarchy = query.getResultList();
		} catch (Exception e) {
			e.printStackTrace();
		}
		return rolesHierarchy;
	}
}

 

UserInfo.java

@Entity
@Table(name="TB_USER")
@Data
public class UserInfo implements GrantedAuthority {
	
	private static final long serialVersionUID = 7877665293796267911L;
	
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int userNo;
	@Id
	private String userId;
	private String password;
	private String userName;
	private String roleId;
	private int point;
	private LocalDateTime lastDate;
	
	@Transient
	private boolean isEnabled = true;

	@Override
	public String getAuthority() {
		return this.roleId;
	}
}

 

RoleHierarchy.java - Hibernate Self Join 찾아서 했는데 루프 돌려가면서 각각의 데이터 확인하려면 계속 DB에서 데이터를 호출해버려서 StackOverflow가 발생해버린다. EntityManager로 데이터를 탐색해야함.

@Entity
@Table(name="SECURITY_ROLE_HIERARCHY")
@Data
public class RoleHierarchy {
    @Id
    @Column(name = "ROLE_ID")
    private String roleId;
    
    @Column(name = "ROLE_NAME")
    private String roleNm;
    
    @ManyToOne(cascade = {CascadeType.ALL})
    @JoinColumn(name = "PARENT_ROLE_ID")
    private RoleHierarchy parentRole;

    @OneToMany(mappedBy = "parentRole", cascade={CascadeType.ALL})
    private Set<RoleHierarchy> roleHierarchy = new HashSet<RoleHierarchy>();
}

 

SecurityServiceImpl.java - getRolesHierarchy 메소드는 전자정부프레임워크에 있던 소스이다.

@Service
public class SecurityServiceImpl implements SecurityService {
	
	@Autowired
	SecurityDAO securityDAO;

	@Override
	public UserInfo getUserByUserId(String userId) {
		return securityDAO.getUserByUserId(userId);
	}

	@Override
	public String getRolesHierarchy() {
		List<RoleHierarchy> rolesHierarchy = securityDAO.getRolesHierarchy();
		Iterator<RoleHierarchy> itr = rolesHierarchy.iterator();
        StringBuffer concatedRoles = new StringBuffer();
        while (itr.hasNext()) {
        	RoleHierarchy model = itr.next();
        	if(model.getParentRole() != null) {
        		concatedRoles.append(model.getParentRole().getRoleId());
                concatedRoles.append(" > ");
                concatedRoles.append(model.getRoleId());
                concatedRoles.append("\n");
        	}
        }
        return concatedRoles.toString();
	}
}

 

login.jsp 설정, username, password가 다른 이름으로 변경 가능할텐데 일단은 패스.

<script type="text/javascript">
	$(function() {
		$('#goLogin').click(function() {
			$('#loginForm').submit();
		});
	});
</script>
<form id="loginForm" method="POST" action="/auth/login/process">
	<div class="form-group">
		<input type="text" class="form-control" id="loginUserId" name="username" placeholder="UserId" />
	</div>
	<div class="form-group">
		<input type="password" class="form-control" id="loginPassword" name="password" placeholder="Password" />
	</div>
</form>

 

이걸 토대로 user로 접속해보면.... /board 에는 접속이 잘 되지만.

 

 

/admin에서는 접근이 막힌다.

 

 

admin 권한을 가지고 있는 경우에는... 보는 바와 같이 /admin에 접근이 가능하다.

 

 

 

 

댓글