관리 메뉴

Just Do it

Part 7) Spring Web Security를 이용한 로그인 처리 (Ch 32) 본문

신입 개발자가 되기 위해 공부했던 독학 자료들/코드로 배우는 스프링 웹프로젝트

Part 7) Spring Web Security를 이용한 로그인 처리 (Ch 32)

Seojoo21 2022. 3. 14. 12:07

* 카테고리의 글은 <코드로 배우는 스프링  프로젝트에서  파트별로 진행하는 프로젝트의 흐름을 보기 위해  장의 내용을 간단히 요약한 것이다.

출처코드로 배우는 스프링  프로젝트 개정판구멍가게 코딩단남가람북스 

 

Ch32 JDBC를 이용하는 간편 인증/권한 처리

- 비록 security-context.xml 파일에 고정된 몇 개의 계정이지만, 로그인 처리가 되었다면 다음 단계는 좀 더 현실적으로 JDBC를 이용하는 방식을 살펴보겠다.

- 앞서 언급했듯이 스프링 시큐리티에서는 사용자를 확인하는 '인증(Authentication)'과 권한 등을 부여하는 '인가 과정(Authorization)'으로 나누어 볼 수 있다.

 

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

- UserDetailsService는 스프링 시큐리티 API 내에 이미 CachingUserDetailsService, InMemoryUserDetailsManager, JdbcDaoImpl, JdbcUserDetailsManager, LdapUserDetailsManager, LdapUserDetailsService와 같은 구현 클래스들을 제공하고 있다. 이전 예제에서 security-context.xml에 문자열로 고정한 방식은 사실 InMemoryUserDetailsManager를 이용한 것이다.

 

- 이번 예제는 기존에 데이터베이스가 존재하는 상황에서 MyBatis나 기타 프레임워크 없이 사용하는 방법을 익혀본다.

- security-context.xml의 기존 <security:user-service>는 아래와 같이 변경될 것이다.

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

 

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

- JDBC를 이용해서 인증/권한을 체크하는 방식은 크게 1) 지정된 형식으로 테이블을 생성해서 사용하는 방식과 2) 기존에 작성된 데이터베이스를 이용하는 방식이 있다.

- 스프링 시큐리티가 JDBC를 이용하는 경우에 사용하는 클래스는 JdbcUserDetailsManager 클래스인데 github 등에 공개된 코드를 보면 SQL 등이 이용되는 것을 확인할 수 있다. 

 

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

create table users(
    username varchar2(50) not null primary key,
    password varchar2(50) not null,
    enabled char(1) default '1');
    
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');

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

- WAS를 실행해서 '/sample/admin'과 같이 인증/권한이 필요한 URI를 호출해 보면 별도의 처리 없이 자동으로 필요한 쿼리들이 호출되는 것을 확인할 수 있다. 

- 하지만 패스워드가 평문으로 처리되었기 때문에 마지막 결과는 예외가 발생한다. 

 

32.1.1 PasswordEncoder 문제 해결

- 앞에서도 언급되었지만 스프링 시큐리티 5부터는 기본적으로 PasswordEncoder를 지정해야만 한다. 

- 앞의 예제에서는 임시로 '{noop}' 접두어를 이용해서 잠시 피해서 진행했었지만, 데이터베이스 등을 이용하는 경우에는 PasswordEncoder라는 것을 이용해야만 한다.

 

- 문제는 패스워드 인코딩을 처리하고 나면 사용자의 계정 등을 입력할 때부터 인코딩 작업이 추가되어야 하기 때문에 할 일이 많다는 점이다. 

- 스프링 시큐리티의 PasswordEncoder는 인터페이스로 설계되어 있고, 이미 여러 종류의 구현 클래스가 존재한다. 

https://docs.spring.io/spring-security/site/docs/4.2.x/apidocs/org/springframework/security/crypto/password/PasswordEncoder.html

- 4버전까지는 위와 같이 별도의 PasswordEncoder를 이용하고 싶지 않을 때 NoOpPasswordEncoder를 이용해서 처리할 수 있었지만, 5버전부터는 Deprecated되어서 더 이상 사용할 수 없다. NoOpPasswordEncoder를 사용할 수 없기 때문에 예제에서는 직접 암호화가 없는 PasswordEncoder를 구현해서 사용한다. 

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

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

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

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

 

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

- 스프링 시큐리티가 기본적으로 이용하는 테이블 구조를 그대로 생성해서 사용하는 방식도 나쁘지는 않지만, 기존의 회원 관련 데이터베이스가 구축되어 있었다면 이를 사용하는 것은 오히려 더 복잡하게 느껴질 수도 있다.

- JDBC를 이용하고 기존에 테이블이 있다면 약간의 지정된 결과를 반환하는 쿼리를 작성해 주는 작업으로도 처리가 가능하다. 

- <security:jdbc-user-service> 태그의 속성 중 'users-by-username-query' 속성과 'authorities-by-user-name-query' 속성에 적당한 쿼리문을 지정해 주면 JDBC를 이용하는 설정을 그대로 사용할 수 있다. 

 

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

- 예제는 일반적으로 사용하는 회원 관련 테이블, 권한 테이블을 설계해서 이를 활용해 본다.

- 이전과 달리 인코딩된 패스워드를 활용해서 좀 더 현실적인 예제를 작성하도록 한다. 

create table tbl_member (
    userid varchar2(50) not null primary key,
    userpw varchar(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));

 

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

- 이번 예제에서는 스프링 시큐리티에서 제공되는 BCryptPasswordEncoder 클래스를 이용해서 패스워드를 암호화해서 처리하도록 한다.

- bcrypt는 태생 자체가 패스워드를 저장하는 용도로 설계된 해시 함수로 특정 문자열을 암호화하고, 체크하는 쪽에서는 암호화된 패스워드가 가능한 패스워드인지만 확인하고 다시 원문으로 되돌리지는 못한다. 

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

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

 

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

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

- 테스트 코드를 실행하기 위해서는 pom.xml에 srping-text가 추가되어야 한다. 

- src/text/java 내에 org.zerock.security 패키지를 생성하고, MemberTests 클래스를 추가한다. 

package org.zerock.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() {
		
		String sql = "insert into tbl_member(userid, userpw, username) values (?,?,?)";
		
		for(int i=0; i<100; i++) {
			Connection con = null;
			PreparedStatement pstmt = null;
			
			try {
				
				con = ds.getConnection();
				pstmt = con.prepareStatement(sql);
				
				pstmt.setString(2, pwencoder.encode("pw"+i));
				
				if(i < 80) {
					pstmt.setString(1, "user"+i);
					pstmt.setString(3, "일반사용자"+i);
				} else if(i < 90) {
					pstmt.setString(1, "manager"+i);
					pstmt.setString(3, "운영자"+i);
				} else {
					pstmt.setString(1,"admin"+i);
					pstmt.setString(3, "관리자"+1);
				}
				
				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) {}}
			
			}
		}
	}
}

- MemberTests에는 PasswordEncoder와 DataSource를 주입해서 100명의 회원 정보를 기록한다.

- PasswordEncoder를 이용해서 암호화된 문자열을 추가하는 과정을 통하기 때문에 위의 코드를 실행하고 나면 BcryptPasswordEncoder를 이용해서 암호화된 패스워드가 기록된 것을 확인할 수 있다. 

 

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

- 사용자 생성이 완료되었다면 tbl_member_auth 테이블에 사용자의 권한에 대한 정보도 tbl_member_auth 테이블에 추가해야한다.

- 'user0~user79'까지는 'ROLE_USER' 권한을, 'manager80~manager89'까지는 'ROLE_MEMBER' 권한을, 'admin90~admin99'까지는 'ROLE_ADMIN'권한을 부여하는 코드를 아래와 같이 작성하여 실행한다. 

@Test
	public void testInsertAuth() {
		
		String sql = "insert into tbl_member_auth (userid, auth) values (?,?)";
		
		for(int i=0; i<100; i++) {
			Connection con = null;
			PreparedStatement pstmt = null;
			
			try {
				
				con = ds.getConnection();
				pstmt = con.prepareStatement(sql);
				
				if(i < 80) {
					pstmt.setString(1, "user"+i);
					pstmt.setString(2, "ROLE_USER");
				} else if(i < 90) {
					pstmt.setString(1, "manager"+i);
					pstmt.setString(2, "ROLE_MANAGER");
				} else {
					pstmt.setString(1,"admin"+i);
					pstmt.setString(2, "ROLE_ADMIN");
				}
				
				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) {}}
			}
		}
	}

 

32.2.3 쿼리를 이용하는 인증 

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

-- users-by-username-query (인증을 하는데 필요한 쿼리) 
select 
	userid, username, userpw as password, enabled
from 
	tbl_member
where userid='admin90'

-- authorities-by-username-query (권한을 확인하는데 필요한 쿼리) 
select 
	userid as username, auth as authority
from
	tbl_member_auth
where userid='admin90'

- 위의 쿼리문을 PreparedStatement에서 사용하는 구문으로 바꾸고 <security:jdbc-user-service> 태그의 속성으로 지정하면 아래와 같은 형태가 된다.

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