스프링 웹 프로젝트 Part7 - JDBC를 이용하는 간편 인증 및 권한 처리

꼼꼼발·2021년 8월 24일
0

security-context.xml파일에 고정된 몇 개의 계정이지만, 로그인 처리가 되었다면 이번 포스트는 JDBC를 이용하는 방식을 이용할 것이다. 스프링 시큐리티에서는 사용자를 확인하는 인증(Authentication)권한등을 부여하는 인가 과정(Authorization)으로 나눌 수 있다.

인증과 권한에 대한 처리는 크게 보면 Authentication Manager를 통해 이루어지는데 이때 인증이나 권한 정보를 제공하는 Provider(존재)가 필요하고, 다시 이를 위해 UserDetailsService라는 인터페이스를 구현한 존재를 활용하게 된다.

UserDetailsService : 스프링 시큐리티 API내에 이미 CachingUserDetailsService, InMemoryUserDetailsManager, JdbcDaoImpl, JdbcUserDetailsManager, LdapUserDetailsManager, LdapUserDetailsService와 같은 구현 클래스들을 제공한다.

이전 프로젝트에서 security-context.xml에 문자열로 고정한 방식은 InMemoryUserDetailsManager을 이용한 것이다.

이번 프로젝트는 기존 DB가 존재하는 상황에 MyBatis나 기타 프레임워크 없이 사용하는 방법을 익힌다. security-context.xml에는 기존의 <security:user-service>태그는 이와 같이 변경될 것이다.

security-context.xml의 일부

<security:jdbc-user-service data-source-ref="dataSource"/>

jdbc-user-service는 기본적으로 DataSource가 필요하기에 root-context.xml에 있는 설정을 추가해야 한다.


JDBC를 이용하기 위한 테이블 설정

JDBC를 이용해서 인증/권한을 체크하는 방식은 크게 두가지가 있다.

  • 지정된 형식으로 테이블을 생성해서 사용하는 방식

  • 기존에 작성된 데이터베이스를 이용하는 방식

스프링 시큐리티가 JDBC를 이용하는 경우 사용하는 클래스는 JdbcUserDetailsManager클래스인데 github등에 공개된 코드를 보면 아래와 같은 SQL등이 이용된다.


출처 - 구멍가게 코딩단 카페

만약 스프링 시큐리티에서 지정된 SQL을 그대로 이용하고 싶다면 지정된 형식으로 테이블을 생성해주면 된다.

스프링 시큐리티의 지정딘 테이블을 생성하는 SQL

-- users 테이블 생성 --
create table users
(
    username varchar2(50) not null primary key,
    password varchar2(50) not null,
    enabled char(1) default '1'
);

-- authorities 테이블 생성 --
create table authorities
(   
    username varchar2(50) not null,
    authority varchar2(50) not null,
    constraint fk_authorities_users foreign key(username) references
    users(username)
);

-- 인덱스 생성 --
create unique index ix_auth_username on authorities (username, authority);

-- 더미 데이터 주입 --
insert into users (username, password) values ('user00','pw00');
insert into users (username, password) values ('member00','pw00');
insert into users (username, password) values ('admin00','pw00');

insert into authorities (username, authority) values ('user00', 'ROLE_USER');
insert into authorities (username, authority) values ('member00', 'ROLE_MANAGER');
insert into authorities (username, authority) values ('admin00', 'ROLE_MANAGER');
insert into authorities (username, authority) values ('admin00', 'ROLE_ADMIN');

commit;

-- 데이터 조회 --
select * from users;
select * from authorities;

security-context.xml<security:authentication-manager> 내용은 아래와 같이 작성된다. 작성전에 root-context.xmldataSource라는 이름의 빈(bean)이 등록되어 있는지 확인하고 진행한다.

security-context.xml의 일부

<security:authentication-manager>
  
  <security:authentication-provider>
	<!-- 646p jdbc-user-service 사용하기 -->
	<security:jdbc-user-service data-source-ref="dataSource"/>
  </security:authentication-provider>
  
</security:authentication-manager>

WAS를 실행해 /sample/admin과 같이 인증/권한이 필요한 URI를 호출해 보면 별도의 처리 없이 자동으로 필요한 쿼리들이 호출된다.

위와 같이 쿼리들이 실행 되었지만 패스워드가 평문으로 처리되었기 때문에 마지막 결과는 예외가 발생한다.

PasswordEncoder 문제 해결
스프링 시큐리티는 5버전 부터 기본적으로 PasswordEncoder를 지정해야만 한다. 임시로 {noop}접두어를 사용해서 잠시 회피했지만 DB를 이용하는 경우 PasswordEncoder라는 것을 이용해야 한다.

하지만 패스워드 인코딩을 처리하고 나면 사용자의 계정 등을 입력할 때부터 인코딩 작업이 추가돼야 하기 때문에 작업 이 많다는 점이다. 스프링 시큐리티의 PasswordEncoder는 인터페이스로 설계되어 있고, 이미 여러 종류의 구현 클래스가 존재한다.

4버전 까지는 PasswordEncoder를 이용하고 싶지 않을 때 NoOpPasswordEncoder를 이용해 처리할 수 있었지만, 5버전 부터 Deprecated돼서 더 이상 사용할 수 없다. NoOpPasswordEncoder를 사용할 수 없기 때문에 프로젝트에서는 직접 암호화가 없는 PasswordEncoder를 구현한다.

security패키지에 CustomNoOpPasswordEncoder클래스를 생성한다.

CustomNoOpPasswordEncoder클래스

package com.seunghwan.security;

import org.springframework.security.crypto.password.PasswordEncoder;

import lombok.extern.log4j.Log4j;

@Log4j
public class CustomNoOpPasswordEncoder implements PasswordEncoder {

	@Override
	public String encode(CharSequence rawPassword) {
		
		log.warn("before encode: " + rawPassword);
		
		System.out.println(rawPassword.toString());
		
		return rawPassword.toString();
	}

	@Override
	public boolean matches(CharSequence rawPassword, String encodedPassword) {
		
		log.warn("matches: " + rawPassword + ":" + encodedPassword);
		
		System.out.println(rawPassword.toString().equals(encodedPassword));
		
		return rawPassword.toString().equals(encodedPassword);
	}

}

PasswordEncoder인터페이스에는 encode()matches()메서드가 존재하므로 위와 같이 직접 처리한다.

security-context.xml에는 작성된 CustomNoOpPasswordEncoder클래스를 빈(Bean)으로 등록한다.

security-context.xml일부

<!-- CustomAccessDeniedHandler 클래스를 Bean으로 등록한다. -->
<bean id="customAccessDenied" class="com.seunghwan.security.CustomAccessDeniedHandler"></bean>
<!-- CustomLoginSuccessHandler 클래스를 Bean으로 등록한다. 639p -->
<bean id="customLoginSuccess" class="com.seunghwan.security.CustomLoginSuccessHandler"></bean>
<!-- CustomNoOpPasswordEncoder 클래스를 Bean으로 등록한다. 649p -->
<bean id="customPasswordEncoder" class="com.seunghwan.security.CustomNoOpPasswordEncoder"></bean>

<security:http>
  
  ...생략...
</security:http>

<security:authentication-manager>
  
  <security:authentication-provider>
    
    <security:jdbc-user-service data-source-ref="dataSource"/>
    
    <security:password-encoder ref="customPasswordEncoder"/>
    
  </security:authentication-provider>
  
</security:authentication-manager>

WAS를 실행해서 로그인을 확인하면 정상적으로 로그인 처리가 JDBC를 이용해서 처리되는 것을 볼 수 있다.


기존 테이블을 이용하는 경우

스프링 시큐리티가 기본적으로 이용하는 테이블 구조를 그대로 생성해서 사용하는 방식도 좋지만 기존의 회원 관련 데이터베이스가 존재한다면 스프링 시큐리티의 테이블 구조를 이용하는 것은 매우 복잡하게 느껴진다. JDBC를 이용하고 기존에 테이블이 있다면 지정된 결과를 반환하는 쿼리를 작성해 주는 작업으로 처리가 가능하다.

<security:jdbc-user-service>태그에는 아래와 같은 속성을 지정할 수 있다.

  • authorities-by-username-query

  • cache-ref

  • group-authorities-by-username-query

  • id

  • role-prefix

  • users-by-username-query

해당 속성들 중 users-b-username-query속성과 authorities-by-user-name-query속성에 적당한 쿼리문을 지정해 주면 JDBC를 이용하는 설정을 그대로 사용할 수 있다.

인증/권한을 위한 테이블 설계

해당 프로젝트는 일반적으로 사용하는 회원 관련 테이블, 권한 테이블을 설계해서 이를 활용하는 방식을 채택할 것이다. 이전과 달리 인코딩된 패스워드를 활용해서 좀 더 활용적인 프로젝트를 만든다.

일반적인 회원 테이블과 권한 테이블

-- 회원 테이블 설계 --
create table tbl_member
(
    userid varchar2(50) not null primary key,
    userpw varchar2(100) not null,
    username varchar2(100) not null,
    regdate date default sysdate,
    updatedate date default sysdate,
    enabled char(1) default '1'
);

-- 회원 인증 테이블 설계 --
create table tbl_member_auth
(
    userid varchar2(50) not null,
    auth varchar2(50) not null,
    constraint fk_member_auth foreign key(userid) references
    tbl_member(userid)
);

BCryptPasswordEncoder 클래스를 이용한 패스워드 보호

해당 프로젝트는 스프링 시큐리티에서 제공되는 BCryptPasswordEncoder클래스를 이용해서 패스워드를 암호화해서 처리하도록 한다. bcrypt는 태생 자체가 패스워드를 저장하는 용도로 설계된 해시 함수로 특정 문자열을 암호화하고, 체크하는 쪽에서는 암호화된 패스워드가 가능한 패스워드인지만 확인하고 다시 원문으로 되돌리지는 못한다.

BCryptPasswordEncoder는 이미 스프링 시큐리티의 API안에 포함되어 있으므로, 이를 활용해 security-context.xml에 설정한다.(기존의 CustomNoOpPasswordEncoder는 사용하지 않을 것이니 주석 처리 또는 삭제한다.)

security-context.xml

<!-- BCryptPasswordEncoder 클래스를 Bean으로 등록한다. 652p -->
<bean id="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>

<security:http>
	...생략...
</security:http>

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

    <security:password-encoder ref="bcryptPasswordEncoder"/>
  </security:authentication-provider>
  
</security:authentication-manager>

bcrypt방식을 이용하는 PasswordEncoder는 이미 스프링 시큐리티에서 제공하므로 이를 빈으로 추가하고, PasswordEncoderorg.springframework.security.crypto.bcrypt.BcryptPasswordEncoder로 지정한다.

인코딩된 패스워드를 가지는 사용자 추가

실제 데이터베이스에 기록하는 회원 정보는 BCryptPasswordEncoder를 이용해 암호화된 상태로 넣어주어야 하므로 테스트 코드를 작성해 처리한다.

src/test/java내에 security패키지를 생성하고 MemberTests클래스를 설계한다.

MemberTests클래스

package com.seunghwan.security;

import java.sql.Connection;
import java.sql.PreparedStatement;

import javax.sql.DataSource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import lombok.Setter;
import lombok.extern.log4j.Log4j;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({
		"file:src/main/webapp/WEB-INF/spring/root-context.xml",
		"file:src/main/webapp/WEB-INF/spring/security-context.xml"})
@Log4j
public class MemberTests {

	@Setter(onMethod_=@Autowired)
	private PasswordEncoder pwencoder;
	
	@Setter(onMethod_=@Autowired)
	private DataSource ds;
	
	@Test
	public void testInsertMember() {
		//삽입SQL문
		String sql = "insert into tbl_member(userid, userpw, username)"
				+ " values(?,?,?)";
		//0부터99까지 100번반복하는 반복문
		for (int i=0; i<100; i++) {
			//커넥션 타입을 갖는 변수를 선언 후 null로 초기화
			Connection con = null;
			//PreparedStatement 타입을 갖는 변수 선언 후 null로 초기화
			PreparedStatement pstmt = null;
			//try-catch
			try {
				//DataSource에서 커넥션 객체를 얻어와서 con변수에 저장
				con = ds.getConnection();
				//커넥션 객체의 prepareStatement()를 이용해 삽입sql문 pstmt에 저장
				pstmt = con.prepareStatement(sql);
				//비밀번호 암호화
				pstmt.setString(2, pwencoder.encode("pw" + i));
				
				if(i < 80) {//0부터 79까지는 일반사용자로 설정
					pstmt.setString(1, "user"+i);
					pstmt.setString(3, "일반사용자"+i);
				} else if (i < 90) {//80부터 89까지는 운영자로 설정
					pstmt.setString(1, "manager"+i);
					pstmt.setString(3, "운영자"+i);
				} else {//90부터 99까지는 관리자로 설정
					pstmt.setString(1, "admin"+i);
					pstmt.setString(3, "관리자"+i);
				}
				//실행된 레코드 수를 int형으로 반환한다.
				pstmt.executeUpdate();
			} catch(Exception e) {
				e.printStackTrace();
			}finally {
				if(pstmt != null) { try { pstmt.close(); } catch(Exception e) {} };
				if(con != null) {try { con.close(); } catch(Exception e) {} };
			}
		}//end for
	}
}

MemberTests에는 PasswordEncoderDataSource를 주입해서 100명의 회원 정보를 기록한다. PasswordEncoder를 이용해서 암호화된 문자열을 추가하는 과정을 통하기 때문에 위의 코드가 실행하고 나면 BCryptPassswordEncoder를 이용해서 암호화된 패스워드가 기록된 것을 확인할 수 있다.

생성된 사용자에 권한 추가하기

사용자 생성이 완료되었다면 tbl_member_auth테이블에 사용자의 권한에 대한 정보도 tbl_member_auth테이블에 추가해야한다. user00~user79까지는 ROLE_USER권한을 manager80~manager89까지는 ROLE_MEMBER권한을 admin90~admin99까지는 ROLE_ADMIN권한을 부여한다.

MemberTests클래스 일부

//사용자 권한 부여
@Test
public void testInsertAuth() {
	//tbl_member_auth테이블에 데이터 주입
	String sql = "insert into tbl_member_auth (userid, auth) values(?,?)";
	
	//0~99까지 100번 반복
	for(int i=0; i<100; i++) {
		
		Connection con = null; //커넥션객체를 저장하는 con변수 선언 및 null로 초기화
		PreparedStatement pstmt = null; //PreparedStatement를 저장하는 pstmt변수 선언 및 null로 초기화
		
		try {
			//DataSource객체의 getConnection()를 이용해 커넥션 객체를 얻어서 con에 저장
			con = ds.getConnection();
			//매개변수화된 SQL문을 데이터베이스로 보내기위해 PreparedStatement 객체를 만든 것을
			//pstmt변수에 저장
			pstmt = con.prepareStatement(sql);
			
			if(i < 80) {//0부터 79까지는 ROLE_USER 권한 부여
				pstmt.setString(1, "user"+i);
				pstmt.setString(2, "ROLE_USER");
			} else if(i < 90) {//80부터 89까지는 ROLE_MEMBER 권한 부여
				pstmt.setString(1, "manager"+i);
				pstmt.setString(2, "ROLE_MEMBER");
			} else {//90부터 99까지는 ROLE_ADMIN 권한 부여
				pstmt.setString(1, "admin"+i);
				pstmt.setString(2, "ROLE_ADMIN");
			}
			//실행된 레코드의 개수를 int형으로 반환한다.
			pstmt.executeUpdate();
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			if(pstmt != null) { try { pstmt.close(); } catch(Exception e) {} }
			if(con != null) {try { con.close(); } catch(Exception e) {} };
		}
	}//end for
}

쿼리를 이용하는 인증

위와 같이 지정된 방식이 아닌 테이블 구조를 이용하는 경우 인증을 하는데 필요한 쿼리(user-by-username-query)와 권한을 확인하는데 필요한 쿼리(authorities-by-username-query)를 이용해서 처리한다.

user-by-username-query

select
	userid username, userpw, password, enabled
from
	tbl_member
where userid = 'admin91'

authorities-by-username-query

select
	userid username, auth authority
from
	tbl_member_auth
where userid = 'admin91'

위 쿼리문을 PreparedStatement에서 사용하는 구문으로 변경하고 <security:jdbc-user-service>태그의 속성으로 지정한다.

security_context.xml

<security:authentacation-manager>
  
  <security:authentication-provider>
    
    	<!-- 657p jdbc-user-service에 PreparedStatement구문 등록 -->
	<security:jdbc-user-service 
	data-source-ref="dataSource"
	users-by-username-query="select userid, userpw, enabled from
	tbl_member where userid = ?" 
	authorities-by-username-query="select userid, auth from
	tbl_member_auth where userid = ?"/>
    
    	<security:password-encoder ref="bcryptPasswordEncoder"/>
  </security:authentication-provider>
  
</security:authentication-manager>

브라우저를 통해 admin90/pw90으로 로그인하면 정상적으로 로그인 되는 것을 확인할 수 있다.

다음 포스트는 커스텀UserDetailsService를 활용하는 포스트를 작성할 것이다.

0개의 댓글

관련 채용 정보