조급하면 모래성이 될뿐

Spring Security활용하여 로그인해보기 본문

구현 기록/SpringSecurity

Spring Security활용하여 로그인해보기

Pawer0223 2020. 1. 31. 15:46

출처

 

처음에 [ 오늘의 개발 ] 블로그에서 무작정 따라하면서 코드를 구현해보았다.

 

대충.. 아.. 이렇게 쓰는거구나..를 느끼고

 

[todyDev] 블로그에서 "아.. 이렇게 되는거구나"를 이해할 수 있었다... ( 설명을 너무 잘 해놓으신 것 같다 )

 

추가로 계속 진행하면서 해당 블로그도 많이 참조하였다..! [ 사랑이고픈 프로그래머 ]

 

개인공부를 목표로 두 블로그의 코드를 실습하면서 개인적으로 중요하다고 생각한 것들위주로 정리를 한것이므로 위 링크를 따라가서 공부를하시는것을 더 추천드립니다..!

 

 

개발환경 정보 ( build.gradle )

plugins {
	id 'org.springframework.boot' version '2.2.4.RELEASE'
	id 'io.spring.dependency-management' version '1.0.9.RELEASE'
	id 'java'
}

group = 'boot.practice'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	testImplementation('org.springframework.boot:spring-boot-starter-test') {
		exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
	}
	//for Db Connect -- 1
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	
	// for Jsp
	compile('org.apache.tomcat.embed:tomcat-embed-jasper')
	compile('javax.servlet:jstl:1.2')
	
	//for Db Connect -- 2
	compile 'com.microsoft.sqlserver:mssql-jdbc'
	
	// MyBatis
    compile("org.mybatis.spring.boot:mybatis-spring-boot-starter:1.3.2")
    compile("org.mybatis:mybatis:3.4.5")
	
	// for Spring Security    
    compile("org.springframework.boot:spring-boot-starter-security")

	
}

test {
	useJUnitPlatform()
}

 

목표

- SpringSecurity를 사용해서 로그인 하기.

 

준비 : DB연동 및 MyBatis 설정

 

[ 링크 ]

 

1. Spring Security 사용을위한 의존성 추가.

 

- build.gradle

// for Spring Security    
compile("org.springframework.boot:spring-boot-starter-security")

 

2. Spring Security 설정 추가.

 

- Java Config방식 사용.

* 각 설정에대한설명은 주석 과 아래 Spel문법을 참조 !

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
public class SecurityConfig extends WebSecurityConfigurerAdapter{
    
    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http
            .authorizeRequests() // 해당 메소드 아래는 각 경로에 따른 권한을 지정할 수 있다.
                .antMatchers("/" , "/login" , "/service" , "/resources/**" , "/create").permitAll() // 로그인 권한은 누구나, resources파일도 모든권한
                .antMatchers("/admin").hasRole("ADMIN"// 괄호의 권한을 가진 유저만 접근가능, ROLE_가 붙어서 적용 됨. 즉, 테이블에 ROLE_권한명 으로 저장해야 함.
                .antMatchers("/user").hasRole("USER")
                .antMatchers("/member").hasRole("MEMBER")
                .anyRequest().authenticated()  //  로그인된 사용자가 요청을 수행할 떄 필요하다  만약 사용자가 인증되지 않았다면, 스프링 시큐리티 필터는 요청을 잡아내고 사용자를 로그인 페이지로 리다이렉션 해준다.
                .and()
            .formLogin() // 하위에 내가 직접 구현한 로그인 폼, 로그인 성공시 이동 경로 설정 가능. , 로그인 폼의 아이디,패스워드는 username, password로 맞춰야 함
                        .loginPage("/login"// 로그인이 수행될 경로.
                        .loginProcessingUrl("/loginProcess")// 로그인form의  action과 일치시켜주어야 함.
                        .defaultSuccessUrl("/loginSuccess"// 로그인 성공 시 이동할 경로.
                        .failureUrl("/login?error=true"// 인증에 실패했을 때 보여주는 화면 url, 로그인 form으로 파라미터값 error=true로 보낸
                .permitAll()
                .and()
             .logout()
                 .permitAll()
                 .logoutUrl("/logout")
                 .logoutSuccessUrl("/")
                 .and()
             .exceptionHandling()
                 .accessDeniedPage("/accessDenied_page"); // 권한이 없는 대상이 접속을시도했을 때
    }
}
cs

 

* Spel 문법

 표현식

설명 

 hasRole('role1')

 권한(role1)을 가지고 있는 경우

 hasAnyRole('role1', 'role2')

 권한들(role1, role2) 하나라도 가지고 있을 경우 (갯수는 제한없다)

 pemitAll

 권한 있든 말든 모두 접근 가능하다.

 denyAll

 권한 있든 말든 모두 접근 불가능하다.

 isAnonymous()

 Anonymous 사용자일 경우 (인증을 하지 않은 사용자)

 isRememberMe()

 Remember-me 기능으로 로그인한 사용자일 경우

 isAuthenticated()

 Anonymous 사용자가 아닐 경우 (인증을 한 사용자)

 isFullyAuthenticated()

 Anonymous 사용자가 아니고 Remember-me 기능으로 로그인 하지 않은 사용자 일 경우


3. 관련 controller 및 JSP파일을 정의.

>> Controller소스링크

>> JSP파일 링크

 

 

4. 계정정보 및 권한관리 테이블 생성

 

* 단순 테스트를위해 무식하게 생성..!

create table user_info (
	id varchar(15),
	password varchar(500),
	isAccountNonexpired int,
	isAccountNonLocked int,
	isCredentialsNonExpired int,
	isEnabled int
)

create table authority (
	username varchar(20),
	authority_name varchar(20)
)

* 테스트위한 계정과 권한도 추가.

INSERT INTO USER_INFO VALUES ( 'TEST' , '1234' , '1','1','1','1' );
INSERT INTO AUTHORITY VALUES ( 'TEST' , 'ROLE_ADMIN' );
INSERT INTO AUTHORITY VALUES ( 'TEST' , 'ROLE_MEMBER' );

 

5. 계정정보테이블에 맞도록 vo생성

 

소스링크

 

계정 테이블 vo클래스는 반드시 UserDetails인터페이스를 구현해야 한다.

 

- UserDetails 인터페이스를 구현 후 오버라이드 된 메서드들을 재정의 해준다.

- 본인의 계정테이블 구조에 맞게 필요한 전역변수들도 추가해준다. ( 일반 vo처럼 )

- 오버라이딩 메서드 중에 getPassword(), getUsername() 메서드는 Security에의해 사용 됨으로  계정id, pw값이 올바르게 return될 수 있도록 신경써야한다.

 

- Override된 메서드 중 isEnabled는 계정활성화 여부컬럼을 확인한다.

- user_info 테이블에서 isEnabled가 0 ( false )이라면 로그인이 불가능하다.

 

6. Service클래스 생성

 

- 우선 DAO를 호출하는 Service클래스를 먼저 작성해주자.

@Service
public class AccountService implements UserDetailsService{
	
	@Autowired
	AccountRepository accounts;
	
	Logger log = LoggerFactory.getLogger(this.getClass());

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		log.info("## loadUserByUsername ##");
		
		Account account = accounts.findById(username);
		
		if( account == null ) {
			log.debug("## 계정정보가 존재하지 않습니다. ##");
			throw new UsernameNotFoundException(username);
		}
		return account;
	}
}

 

- UserDetailService 인터페이스를 구현한 Service 클래스를 생성하였다.

 

- 해당 인터페이스를 구현함으로서, 반드시 오버라이딩해야 할 메서드가 loadUserByUsername()이다.

 

- loadUserByUsername()메서드는 SpringSecurity의 속성으로 지정한 loginProcessingUrl("/loginProcess")

에 해당하는 URL이 호출될 때 수행되어진다.

 

- 해당 메서드에 내가 재정의할 기능은 입력받은 id를 인자값으로, 계정정보를 가지고온다.

* findById 는 MyBatis를 통해 정보를 가지고 올 수 있도록 우리가 정의할 메소드이다.

 

- 이 메서드가 호출될 때 나의DB에 접근해서 계정정보를 가져오고 있다.

 

7. DAO용도의 클래스, Mapper 생성

 

- findById메서드를 사용하기위한 코드를 작성해주자.

 

1) 신규 DAO용도 클래스 정의 ( @Repository )

@Repository
public class AccountRepository {

	@Autowired
	AccountMapper accountMapper;

	public Account findById(String username) {
		return accountMapper.readAccount(username);
	}
}

findById

- 입력받은 id값으로 계정정보 조회하기 위함.

- 해당 메서드가 호출되면 readAccount가 호출된다.

 

2) DAO에서 호출하는 Mapper 인터페이스와 , 호출 Xml 정의

@Mapper
public interface AccountMapper {
	Account readAccount(String id);
}

*AccountMapper.xml

- Xml 파일명은 무조건 interface명과 동일해야 함

- 호출 SQL의 id값은 메서드명과 동일해야 함.

	<select id="readAccount"  parameterType="String" resultType="com.boot.test1.vo.Account">
		SELECT * FROM USER_INFO
		WHERE ID= #{id}
	</select>

8. 마지막으로 loginPage를 customize해준다.

 

지켜야할 규칙들은 아래와 같다.

 

- 폼 태그의 action에 해당하는 URL을 반드시, SpringSecurity의 loginProcessingUrl("url")과 동일하게 맞춰야 한다.

 

- id,pw에 해당하는 input태그의 name을 username, password로 맞춰준다.

* 해당 input태그의 name을 변경하면, SpringSecurity의 설정도 동일한 이름으로 처리 되도록 변경해주어야 한다.

 

- csrf처리를위하여 아래 input태그를 hidden으로 같이 작성해주어야 한다.

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />

 

* SpringSecurity가 기본적으로 제공하고있는 loginForm양식은 아래와 같다.

<body onload="document.f.username.focus();">
    <h3>Login with Username and Password</h3>
    <form name="f" action="/login" method="POST">
        <table>
            <tbody>
                <tr>
                    <td>User:</td>
                    <td><input type="text" name="username" value=""></td>
                </tr>
                <tr>
                    <td>Password:</td>
                    <td><input type="password" name="password"></td>
                </tr>
                <tr>
                    <td colspan="2"><input name="submit" type="submit" value="Login"></td>
                </tr>
                <input name="${_csrf.parameterName}" type="hidden" value="${_crsf.token}">
            </tbody>
        </table>
    </form>
</body>

 

해당 규칙들을 지키면서 정의한 최종 로그인페이지다.[ 소스코드 링크 ]

 

로그인 폼의 아래코드는 로그인 실패시 에러메시지를 출력해주기 위해 추가하였다.

<c:if test="${not empty SPRING_SECURITY_LAST_EXCEPTION}">
	<font color="red">
		<p>
			Your login attempt was not successful due to <br /> 
			${sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message}
		</p> 
		<c:remove var="SPRING_SECURITY_LAST_EXCEPTION" scope="session" />
	</font>
</c:if>

해당 코드에서 몇가지를 설명하자면, 

 

1) SPRING_SECURITY_LAST_EXCEPTION

- SpringSecurity는 작업을 하면서 예외가 발생하면 해당 예외에 대한 객체를 만들어서 세션에 저장한다.

- 이 세션이 저장되는 key의 이름이 SPRING_SECURITY_LAST_EXCEPTION이다.

- 여기서 if조건절에 해당 세션이 empty가 아닌경우에는 빨간 글씨로 에러메시지를 출력하도록 해주었다.

 

2) sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message

- 에러세션에 저장되어있는 메시지를 출력하기위한 구문이다.

 

그러나, 세션을 통한 에러메시지 출력은 바람직하지 않다.

에러로인한 인증세션을 의도적으로 생성되도록 해킹을 시도한다면, WAS의 메모리가 가득차는 상황이 발생하여 FULL GC가 발생하게되어 응답이 늦어지게 될것이다.

 

세션을 사용하지 않고 해결하기위해서 authentication-success-handler-ref, authentication-failure-handler-ref 속성을 사용하여 로그인 성공,실패에 따른 처리를 제어할 수 있다. ( 성공 , 실패 )

 

이제 모든 준비를 맞췄다.. 

 

테스트해보자.

 

먼저, 등록되지 않은 계정으로 로그인을 시도하였더니 에러세션에서 메시지를 출력한다.

 

- 여기서 에러세션에서 출력한 메시지 "자격 증명에 실패하였습니다"는 message.properties파일에서 에러에해당하는 key값의 값을 출력하는것이다.

( spring-security-core-5.2.1.RELEASE.jar > org.springframework.security > message_ko_KR.properties )

 

- 동일한 key값으로 메시지를 직접 재정의해줄수도있다. ( 난 안했음.. )

[ 해보고싶다면.. 해당글 참조 ]

 

두번째로 insert해준 계정 "TEST"로 시도해보았다.

 

이번에는 에러메시지가 화면에 출력되지않았는데, 콘솔창에 아래와같이java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" ERROR가 발생하였다.

에러를 해결하기위해 SecurityConfig클래스에 아래 코드를 추가해서 bcrypt방식의 인코딩을 수행하도록 해주 었다. [ 참조 : 에러의 원인 및 2가지 해결방법 ]

@Bean
public static PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

 

그래도 아직 에러가 존재하므로 임시방편으로 계정의 비밀번호를 암호화된 pw로 변경하자.

 

이후부터 생성되는 계정은, 생성하는 과정에서 SpringSecurity를 통해 인코딩 된 PW만 입력되도록하자..

( 사실 계정을 insert로 넣는게 단순히 테스트를 위한 행위였으니.. 지양하자.. )

UPDATE USER_INFO SET password = '{bcrypt}$2a$10$KmeQMmC62oTmxf6Lv56NDOVCGR5LNh5VkjMe98741HNQg14TBsNPO'
WHERE USERID='TEST';

* 참고로 해당 password는 1234를 bcrypt방식으로 돌려서 만들어낸 pw이다.. 1234가아닌 다른 pw를 사용한다면.. 해당비밀번호로 encode하던가, 그냥 1234를 쓰자..!

 

그래도 안되면.. [ 링크 ] 이 클래스의 encode()함수에 password변수의 값을 사용 할 비밀번호로 변경한 후에 수행해서 " encPassword : " 뒤에 출력되는 값으로 update하면될것이다.. 나머지 출력문은 test용도로 찍어놓은것이니 신경안써도 된다.

 

드디어 정상적으로 로그인에 성공했다 !

 

그리고 TEST계정으로 /admin , /user , /member의 url을 직접입력해 접근을 시도하면 모두 권한이 없는 페이지 에러페이지로 이동될 것이다 ( config설정의 /accessDenied_page를 타서 )

 

이유는 당연히 우리가등록한 TEST계정에는 부여된 권한이 존재하지 않기 때문이다.

 

고로,계정에 권한을 부여하고, 권한에 따른 URL의 접근제어가 가능하도록 기능을 추가해주어야 한다.

 

즉, TEST계정이 ADMIN, MEMBER의 권한을 가졌을때, USER권한이 필요한 URL에 접근을 할 수 없다.

 

후기

 

SpringSecurity를 사용해서 로그인을 수행하였다.

 

주의 할 점이 많았다. SpringSecurity를 사용하기위한 규약을 많이 따라야했다.

 

- vo, service 클래스를 정의할때나, 계정정보를 loadUserByUsername에서 가져와야하고, input태그의 name속성도 정해져있고.. , DB에 암호화 된 pw가 있어도 에러가나고..

 

- 그러나 여러 규약들을 지켜가면서 interface의 장점을 조금 이해할 수 있었다.

 

- 적어도.. 보안을 잘모르는 사람들은 SpringSecurity를 쓰기위해 필요한 셋팅만 잘 한다면, 최소한의 보안은 지킬 수 있겠구나.. 라고 생각이들었다.

 

- 그리고 SpringSecurity라이브러리를 다운받은 후 적용해주면 별도의 로그인페이지를 없더라도 로그인창이뜨게되는데, SpringSecurity가 기본적으로 제공하고있는 로그인Page가존재하기 때문이다.

 

- 이 로그인Page로 로그인을 시도할때 호출되는 Action(?)들이 SpringSecurity가 처리하기 위한 규칙에 맞추어 작성되어있다.

 

- SpringSecurity를 사용하면서 기본적으로 제공되는 LoginPage가아닌 내가만든 LoginPage를 사용하고싶다면 설정에서 변경해 줄 수 있다.

 

- 그러나 내가만든 LoginPage를 작성할때는 반드시 SpringSecurity을 사용하기위한 규약을 잘 지켜서 작성해야한다.

 

* 내가참조한 블로그는 SpringSecurity설정을 Xml로하였는데, 나는 Java Configuration방식을사용해보았다. 차이점은 spring-security사이트에서 검색해서 해결했다. [ 링크 ]

 

반응형