Spring 4/Security 4

Spring Security Custom AuthenticationProvider

Soul-Learner 2016. 5. 15. 22:10

스프링 시큐리티 커스텀 AuthenticationProvider 적용 예


스프링 Security 와 연동되는 인증로직의 거의 모든 부분을 개발자가 작성할 수도 있다. 스프링 Security 의 인증로직 대부분을 개발자가 작성한다면 스프링 Security 를 사용하는 장점은 줄어들지 않을까 하는 생각을 할 수가 있는데, 스프링 Security는 인증서비스만 제공하는 것이 아니라 인가, UI 서비스도 제공하기 때문에 인증부분은 개발자가 작성하여 스프링 Security 와 연동할 수 있다면 인가(Authorization) 서비스와 UI 서비스 등은 그대로 사용할 수가 있기 때문에 여전히 큰 장점을 지니게 된다고 볼 수 있다



인증(Authentication)로직의 커스터마이징에 대한 장단점

단점 : 개발자가 최소한 2개 이상의 클래스(AuthenticationProvider, UserDetailsService)를 직접 작성해야 한다

장점 : 자동화하기 어렵거나 까다로운 인증절차도 개발자가 구현할 수 있다.

인증을 제외한 스프링 Security 의 모든 기능( 인가, UI )을 그대로 이용할 수 있다



인메모리(In-Memory) 인증 설정의 예

     <authentication-manager>
        <authentication-provider>
        	<user-service >
        		<user name="smith"  password="smith_pw"  authorities="USER" />
        		<user name="blake"  password="blake_pw"  authorities="USER_MANAGER" />
        		<user name="king"  password="king_pw"  authorities="USER_ADMIN" />
 			</user-service>
        </authentication-provider>
    </authentication-manager>



users, authorities 테이블을 이용하여 인증하는 설정의 예

     <authentication-manager>
        <authentication-provider>
        	<jdbc-user-service data-source-ref="dataSource"  />
        </authentication-provider>
    </authentication-manager>



임의의 DB 테이블을 사용하여 인증하도록 설정하는 예

    <authentication-manager>
        <authentication-provider>
        	<jdbc-user-service 
        		id="jdbcUserService"
        		data-source-ref="dataSource"
        		users-by-username-query="select id, pwd, enabled from member where id=?"
        		authorities-by-username-query="select id, authority from member where id=?"/>
        </authentication-provider>
    </authentication-manager>

<jdbc-user-service> 태그를 사용한 위의 설정은 다음과 같은 클래스가 디폴트로 사용된다


JdbcUserDetailsManager(UserDetailsService, UserDetailsManager의 구현체)

 - 이용자 이름을 이용하여 DB로부터 이용자 정보를 가져와서 UserDetails 타입으로 리턴한다


DaoAuthenticationProvider(AuthenticationProvider의 구현체)

 - UserDetailsService를 이용하여 DB로부터 이용자 정보를 가져와서 화면에서 입력한 정보와 비교한다



아래는 패스워드 인코더를 추가한 경우

 - DB에 암호화되어 저장된 이용자의 비밀번호와 화면에서 입력된 이용자의 비밀번호를 비교할 때 화면에서 입력된 이용자의 비밀번호는 암호화되어 있지 않으므로 패스워드 인코더를 이용하여 암호화한 후에 비교한다

    <authentication-manager>
        <authentication-provider>
        	<jdbc-user-service 
        		id="jdbcUserService"
        		data-source-ref="dataSource"
        		users-by-username-query="select id, pwd, enabled from member where id=?"
        		authorities-by-username-query="select id, authority from member where id=?"/>
		<password-encoder ref="encoder"/>
	</authentication-provider>
    </authentication-manager>


위의 설정에서는 스프링이 제공하는 JdbcUserDetailsManager와 DaoAuthenticationProvider 를 사용하여 이용자를 인증할 수 있는 방법이었다.



스프링 Security 에서 커스텀 인증절차 작성

개발자가 인증절차의 대부분을 직접 구현하려면 AuthenticationProvider 인터페이스를 구현하여 authenticate() 메소드를 오버라이드하면 된다

authenticate()메소드의 파라미터로 화면에서 입력한 로그인 정보가 전달되며, DB로부터 이용자 정보를 가져오려면 authenticate() 메소드 내에서 UserDetailsService 등을 이용하면 된다. DB에서 가져온 이용자의 비밀번호가 암호화되어 있는 경우에는 우선 PasswordEncoder를 이용하여 화면에서 입력된 비밀번호를 암호화하여 비교해야 한다


Spring Security 를 이용하여 로그인 기능을 구현하면 스프링은 AuthenticationProvider 클래스의 Authentication authenticate(Authentication authentication )메소드를 이용하여 데이터베이스에 저장된 데이터와 이용자가 입력한 로그인 정보를 비교하게 된다. authenticate()메소드의 파라미터로 전달된 Authentication 객체는 이용자가 화면에서 입력한 로그인 정보가 저장되어 있다. 


authenticate() 메소드 안에서는 UserDetailsService가 사용되어 데이터베이스로부터 이용자의 이름을 이용하여 이용자 정보를 UserDetails 형의 객체로 가져온다. 


authenticate()메소드를 개발자가 오버라이드하여 인증로직을 작성할 때는 이용자 입력정보(Authentication)와 DB의 이용자 정보(UserDetails)를 비교하여 일치하면 Authentication 참조를 리턴하고 일치하지 않으면 AuthenticationException 예외를 던져야 한다


UserDetailsServiceloadUserByUsername(String username) 메소드에서는 개발자가 정의한 DAO를 이용하여 DB로부터 이용자 정보를 가져와서 UserDetails 객체의 참조를 리턴하고 그런 이름이 DB에서 발견되지 않으면 UsernameNotFoundException 을 던지면 된다.


위에서 언급한 AuthenticationProvider, UserDetailsService, UserDetails, GrantedAuthority 등은 모두 인터페이스이므로 실제 사용하려면 적당히 구현한 클래스를 사용해야 한다



servlet-context.xml ( 콘트롤러 없이 이용자의 요청을 바로 뷰에 연결하기 위한 설정 부분 )


요청에 콘트롤러를 연결하지 않는 경우에는 아래처럼 요청을 뷰에 바로 연결하도록 설정하면 된다

<view-controller path="/index" view-name="sec/index" />
<view-controller path="/admin/usermanager/main" view-name="sec/usermanagerMain" />
<view-controller path="/member/main" view-name="sec/memberMain" />
<view-controller path="/login" view-name="sec/login" />
<view-controller path="/logout" view-name="sec/logout" />



root-context.xml ( 커스텀 AuthenticationProvider를 사용하기위한 설정 )

<?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:jee="http://www.springframework.org/schema/jee"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/security 
          http://www.springframework.org/schema/security/spring-security-4.1.xsd
          http://www.springframework.org/schema/jee 
          http://www.springframework.org/schema/jee/spring-jee.xsd
          http://www.springframework.org/schema/context 
          http://www.springframework.org/schema/context/spring-context.xsd
          http://www.springframework.org/schema/beans 
          http://www.springframework.org/schema/beans/spring-beans.xsd">
                                            
     <context:component-scan base-package="org.kdea.sec" />
    
    <beans:bean id="dataSource" class="oracle.jdbc.pool.OracleDataSource"> 
        <beans:property name="dataSourceName" value="ds"/>
        <beans:property name="URL" value="jdbc:oracle:thin:@localhost:1521:xe"/>
        <beans:property name="user" value="scott"/>
        <beans:property name="password" value="TIGER"/>
    </beans:bean>
          
    <http auto-config='true'  use-expressions="true">
        <intercept-url pattern="/admin/usermanager/**"  access="hasAuthority('USER_MANAGER')" />
        <intercept-url pattern="/manager/**"  access="hasRole('USER')" />
        <intercept-url pattern="/member/**"  access="isAuthenticated()" />
        <intercept-url pattern="/**"  access="permitAll" />
        <form-login
            login-page="/login"
            login-processing-url="/user/login"
            username-parameter="id"
            password-parameter="pwd"
            default-target-url="/index"
            authentication-failure-url="/sec/login?error=true"
            />
        <logout logout-success-url="/sec/welcome" />
        <csrf disabled="true"/>
        <!-- Spring security automatically enables csrf, which automatically disabled GET logouts. 
        You can fix this by disabling csrf protection by settings <csrf disabled="true"/> in the <http> , 
        or just using a POST. -->
    </http>

<!-- 	
    <authentication-manager>
        <authentication-provider>
            <jdbc-user-service
                id="jdbcUserService"
                data-source-ref="dataSource"
                users-by-username-query="select id, pwd, enabled from member where id=?"
                authorities-by-username-query="select id, authority from member where id=?"/>
        </authentication-provider>
    </authentication-manager>
 --> 
 
    <beans:bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
    <beans:bean id="customAuthenticationProvider" class="org.kdea.security.CustomAuthenticationProvider" />
	
    <authentication-manager>
        <authentication-provider ref="customAuthenticationProvider"  />
    </authentication-manager>
    
</beans:beans>



UserService.java ( 커스텀 UserDetailsService 구현 예)

package org.kdea.sec;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserService implements UserDetailsService 
{
	@Autowired
	private UserAuthDAO userAuthDAO;

	@Override
	public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
		//데이터베이스에서 가져온 이용자 정보
		UserDetails userDetails = userAuthDAO.getUserDetails(userId);
		if (userDetails==null) throw new UsernameNotFoundException("접속자 정보를 찾을 수 없습니다.");
		return userDetails;
	}
}



UserAuthDAO.java

package org.kdea.sec;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
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.stereotype.*;

@Component
public class UserAuthDAO 
{
	private Connection conn;
	private PreparedStatement pstmt;
	private ResultSet rs;
	
	@Autowired
	private DataSource dataSource;
	
	private Connection getConn()
	{
	   try{
		 conn = dataSource.getConnection();
	     return conn;
	   }catch(Exception e){
		   e.printStackTrace();
		   if(conn!=null){
				try {
					conn.close();
				} catch (SQLException sqle){
					sqle.printStackTrace();
				}
		   }
	   }
	   return null;
	}
	
	private void closeAll() {
		try{
			if(rs!=null) rs.close();
			if(pstmt!=null) pstmt.close();
			if(conn!=null) conn.close();
		}catch(SQLException sqle){
			sqle.printStackTrace();
		}
	}

	
	public UserDetails getUserDetails(String userId){
		conn  = getConn();
		String sql = "SELECT id,pwd,authority FROM member WHERE id=?";
		try {
			pstmt = conn.prepareStatement(sql);
			pstmt.setString(1, userId);
			rs = pstmt.executeQuery();
			if(rs.next()){
				
				String id = rs.getString("ID");
				String pwd = rs.getString("PWD");
				String role = rs.getString("AUTHORITY");
				
				List<GrantedAuthority> roles = new ArrayList<>();
				roles.add(new SimpleGrantedAuthority(role));
				User user = new User(id, pwd, roles);
				return user;
			}
		} catch (SQLException e) {
			e.printStackTrace();
		} finally{
			closeAll();
		}
		
		return null;
	}
}



CustomAuthenticationProvider.java (커스텀 AuthenticationProvider 작성 예)

BcryptPasswordEncoder 사용법은 다른 페이지를 참조하세요


package org.kdea.sec;

import java.util.*;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.*;
import org.springframework.security.core.*;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class CustomAuthenticationProvider implements AuthenticationProvider 
{
	@Autowired
	private UserService userService;
	
	@Autowired
	private BCryptPasswordEncoder passwordEncoder;
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException 
	{
		String username = authentication.getName();
        String password = (String) authentication.getCredentials();
 
        User user = null;
        Collection<GrantedAuthority> authorities = null;
 
        try {
            user = (User)userService.loadUserByUsername(username);
 
            // 이용자가 로그인 폼에서 입력한 비밀번호와 DB로부터 가져온 암호화된 비밀번호를 비교한다
            if (!passwordEncoder.matches(password, user.getPassword())) 
                    throw new BadCredentialsException("비밀번호 불일치");
 
            authorities = user.getAuthorities();
        } catch(UsernameNotFoundException e) {
            e.printStackTrace();
            throw new UsernameNotFoundException(e.getMessage());
        } catch(BadCredentialsException e) {
            e.printStackTrace();
            throw new BadCredentialsException(e.getMessage());
        } catch(Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }
 
        return new UsernamePasswordAuthenticationToken(username, password, authorities);
	}

	@Override
	public boolean supports(Class<?> arg0) {
		return true;
	}

}



커스텀 로그인 폼

WEB-INF/views/sec/login.jsp

<%@page import="java.util.Enumeration"%>
<%@ page contentType="text/html; charset=utf-8"
    pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>로그인</title>
<style type="text/css">
form {width:300px;}
#errMsg { background-color: rgb(255, 200,200);}
</style>
</head>
<body>
<p>
<h3>로그인</h3>
<c:if test="${not empty param.error}">
	<span id="errMsg">오류: ${SPRING_SECURITY_LAST_EXCEPTION.message}</span>
</c:if>

<form action="<c:url value='/user/login'/>" method="post">
<div>아이디 <input type="text" name="id" value="JONES"></div>
<div>암 호 <input type="password" name="pwd" value="JONES"></div>
<div><button type="submit">로그인</button></div>
</form>

</body>
</html>