본문 바로가기
SpringBoot

[Spring Boot] 21. Spring Security (2) - Customize Form

by 청양호박이 2019. 12. 25.

지난 번에는 Spring Security를 사용해서 Authentication을 사용하는 방법을 확인해 보았습니다. 그 중에서도 formlogin을 활용해서... 기본적으로 제공하는 /login을 통해 id/pass를 입력하고 해당 정보가 AuthenticationManager를 통해서 전달된 것 까지 확인해 보았습니다. 

 

하지만 사실 Form을 통해서 login을 구현할때, 기본적으로 제공하는 화면을 그대로 사용하는 경우는 많지 않다고 생각됩니다. 역시 Spring Security에서도 사용자가 만든 form을 통해서 구현할 수 있게 메소드를 제공합니다. 그럼 이번에는 Customize Form을 사용해서 login/logout기능을 구현해 보겠습니다.

 

 

1. 신규 login 페이지 적용


temp로 제작하는 view의 동작 페이지는 아래와 같습니다. home.html은 인증없이 접근가능한 페이지고, hello.html은 인증이 필요한 페이지 입니다. 앞으로 작업하는 html파일은 이전에 의존성을 추가한 thymeleaf 를 적용한 페이지 입니다. 또한, 파일의 경로는 "src/main/resources/templates/" 이하에 위치합니다.

[동작로직]

[home.html]

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Example</title>
    </head>
    <body>
        <h1>Welcome!</h1>

        <p>Click <a th:href="@{/hello}">here</a> to see a greeting.</p>
    </body>
</html>

[hello.html]

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Hello World!</title>
    </head>
    <body>
        <h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
        <form th:action="@{/logout}" method="post">
            <input type="submit" value="Sign Out"/>
        </form>
    </body>
</html>

[login.html]

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Example </title>
    </head>
    <body>
        <div th:if="${param.error}">
            Invalid username and password.
        </div>
        <div th:if="${param.logout}">
            You have been logged out.
        </div>
        <form th:action="@{/login}" method="post">
            <div><label> User Name : <input type="text" name="username"/> </label></div>
            <div><label> Password: <input type="password" name="password"/> </label></div>
            <div><input type="submit" value="Sign In"/></div>
        </form>
    </body>
</html>

 

이제는 Customize된 login페이지를 적용하기 위해서 Security Config 부분을 수정합니다. 

 

[SecurityConfiguration.java]

package com.example.ayoteralab.main.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import com.example.ayoteralab.main.auth.MyAuthenticationProvider;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
	
	@Autowired
	MyAuthenticationProvider myAuthenticationProvider;
	
	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
			.antMatchers("/", "/home").permitAll()
			.anyRequest().authenticated();
		http.formLogin()
			.loginPage("/userlogin")
//			.usernameParameter("id")
//			.passwordParameter("pass")
			.defaultSuccessUrl("/#/todo")
			.permitAll();
		http.logout()
			.logoutUrl("/logout")
			.logoutSuccessUrl("/home")
			.invalidateHttpSession(true)
			.permitAll();
		http.exceptionHandling().accessDeniedPage("/user/deny");
	}
	
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.authenticationProvider(myAuthenticationProvider);
	}
	
}

기존대비 추가된 부분은 http.formLogin()에 .loginPage( )를 입력한 것 입니다. 해당 부분을 정의하지 않으면 기본적으로 제공하는 form login을 /login을 통해서 접근이 가능한 것이며, 정의를 하게 된다면 해당 경로로 요청이 들어올 때, 동일한 기능을 제공하게 되도록 변경이 되는 것입니다. 

 

추가적으로 form에서 아이디와 패스워드를 넣는 input의 이름을 변경하고 싶을 수 있는데... 이는 usernameParameter 와 passwordParameter로 변경이 가능합니다. (Default : username / password)

 

이 상태로 localhost/home에 접근하게 되면... 404 error가 발생합니다. 이유는... 그 어디에도 /home으로 들어오면 home.html을 띄우라는 정의가 없기 때문입니다. 방법은 2가지가 있습니다. 

  • WebConfig.java를 이용
  • Controller.java를 이용

이번에는 WebMvcConfigurer 인터페이스를 상속받은 WebConfig를 통해서 구현하겠습니다.

 

[WebConfiguration.java]

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

	@Override
	public void addViewControllers(ViewControllerRegistry registry) {
		registry.addViewController("/home").setViewName("home");
	    registry.addViewController("/").setViewName("home");
	    registry.addViewController("/hello").setViewName("hello");
	    registry.addViewController("/userlogin").setViewName("userlogin");
	}
	
}

addViewController에는 mapping이 되는 경로 주소가 들어가고, setViewName은 templates내 보유하고있는 html파일의 이름이 되겠습니다.

 

실제로 localhost/home으로 접속하게되면, 아래와 같이 뜨게 됩니다. 이때 링크를 클릭하면, 코드에 따라 /hello로 이동을 할텐데, Security Config에 따르면...  .antMatchers("/", "/home").permitAll() 만 누구나 접속이 가능하지만, 그를 제외한 나머지 페이지는 anyRequest().authenticated(); 설정으로 인증이 없는 접속에 대해서는 /userlogin페이지로 우회되게 됩니다.

현재 AuthenticationProvider.java에서는 모든 접속에 대해서 true를 리턴하기 때문에 login에 아무런 ID/PASS를 입력하게된다면, 바로 /hello페이지를 확인할 수 있습니다.

서버에서도 정상적으로 Authentication이 생성됨을 확인하였습니다. 

 

 

2. User Table 생성 및 추가


실제로 login은 입력된 ID/PASS를 기준으로, 실제 DB에 등록되어있는 사용자와 동일한지를 판단하고 인증을 수행하기 때문에 앞으로 사용할 User Table을 DB에 생성하고 test id를 하나 추가하도록 하겠습니다. 

 

CREATE TABLE user_info (
    USER_ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    LOGIN_ID VARCHAR(50),
    LOGIN_PASS VARCHAR(100),
    FULL_NAME VARCHAR(100),
    EMAIL VARCHAR(100),
    CREATE_DATE TIMESTAMP NOT NULL,
    LAST_LOGIN_PASS_UPDATE_DATE TIMESTAMP,
    DELETE_YN VARCHAR(10),
    LOGIN_FAIL_COUNT INT(2)
);

INSERT INTO user_info
    (LOGIN_ID, LOGIN_PASS, FULL_NAME, EMAIL, CREATE_DATE, LAST_LOGIN_PASS_UPDATE_DATE, DELETE_YN, LOGIN_FAIL_COUNT)
VALUES
    ('ayoteralab', 'test', 'ayoteralab', 'test@go.go', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'N', 0); 

요렇게 table을 생성하여 데이터를 한개 넣었습니다.

 

 

3. DTO와 Service 생성


제목은 매우 가볍게 DTO와 Service의 생성이라고 했지만, 실제로 Spring Security에서 사용자의 정보를 저장하고 처리하는데는 정해진 class와 메서드, 방법이 존재합니다. 바로 UserDetails와 UserDetailsService 입니다.

 

  • UserDetails : 사용자의 정보를 저장하는 인터페이스로 관련 class에서 implements하게 되면 자동으로 메서드를 override해서 사용하게 됩니다. 일반적으로 이야기하는 DTO정도가 되는 위치입니다. 메서드는 총 7개를 제공하고 기능별로 구현 후 호출해서 사용하면 됩니다.
  • UserDetailsService : Spring Security에서 사용자의 정보를 가져오는 인터페이스로 loadUserByUsername( )을 override해서 사용합니다.

[MyUserDetails.java]

public class MyUserDetails implements UserDetails {
	
	private static final long serialVersionUID = -6528817391890633678L;
	private String loginId;
	private String loginPass;

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public String getPassword() {
		// TODO Auto-generated method stub
		return loginPass;
	}

	@Override
	public String getUsername() {
		// TODO Auto-generated method stub
		return loginId;
	}

	@Override
	public boolean isAccountNonExpired() {
		// TODO Auto-generated method stub
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		// TODO Auto-generated method stub
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		// TODO Auto-generated method stub
		return false;
	}

	@Override
	public boolean isEnabled() {
		// TODO Auto-generated method stub
		return false;
	}
	
}

해당 class에서는 UserDetails 인터페이스의 메서드들을 @Override해서 사용합니다. 만들때 한가지 warning이 발생하는데 그 이유는 직렬화 때문이다. 세부적인 내용은 아래 링크를 참조해 주세요.

(error : the serializable class does not declare a static final serialversionuid field of type long)

2019/12/25 - [SpringBoot] - [Spring Boot][Error] the serializable class does not declare a static final serialversionuid field of type long

 

[Spring Boot][Error] the serializable class does not declare a static final serialversionuid field of type long

해당 error는 사실은 error까지는 아니고... warning에 해당합니다. (the serializable class does not declare a static final serialversionuid field of type long) 객체를 transfer하거나 write하기 위해서는..

ayoteralab.tistory.com

 

[MyUserDetailsService.java]

@Service
public class MyUserDetailsService implements UserDetailsService {

	@Autowired
	UserInfoMapper userInfoMapper;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		MyUserDetails mud = userInfoMapper.getUserInfoById(username);
		return mud;
	}
	
}

[UserInfoMapper.java]

public interface UserInfoMapper {

	MyUserDetails getUserInfoById(@Param("username") String username);

}

[UserInfoMapper.xml]

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.ayoteralab.main.mapper.UserInfoMapper">

<select id="getUserInfoById" resultType="com.example.ayoteralab.main.dto.MyUserDetails">
<![CDATA[
	SELECT
		*
	FROM user_info
	WHERE LOGIN_ID = #{username}
]]>
</select>

</mapper>

 

 

4. AuthenticationProvider 구현


현재 AuthenticationProvider는 DB와의 체크로직이 빠져있는데, service의 loadUserByUsername( )를 호출해서 정보가 없거나 pass가 상이할경우 로그인을 수행하지 않고, 일치하는 경우만 로그인이 되도력 변경하겠습니다.

 

[MyAuthenticationProvider.java]

@Component
public class MyAuthenticationProvider implements AuthenticationProvider {

	@Autowired
	MyUserDetailsService myUserDetailsService;
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {

		String loginId = (String) authentication.getPrincipal();
		String loginPass = (String) authentication.getCredentials();
		
		System.out.println(loginId);
		System.out.println(loginPass);
		System.out.println(authentication);
		
		MyUserDetails mud = (MyUserDetails) myUserDetailsService.loadUserByUsername(loginId);
		
		if(mud == null || !mud.getPassword().equals(loginPass)) return null;
		
		//(principal, credentials, authorities)
		return new UsernamePasswordAuthenticationToken(loginId, loginPass, null);
	}

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

 DB에 대상 정보자체가 없거나, Pass가 다를경우 null을 리턴하고 아닌경우는 정상 Token을 발급합니다.

 

 

5. 테스트


강제로 입력한 ayoteralab / test로 로그인한 경우만, 정상적으로 hello를 보게 될 것이고 아닌 경우는 실패하게 됩니다.

null로 리턴하면 error로 미리 설정한 text가 표출됩니다. 반면에 정상적으로 입력한 경우에는...

이제 sign out 버튼을 누르면 다시... /home으로 이동합니다. 

이렇게 Customize Form으로 Spring Security를 통한 로그인/로그아웃을 구현했습니다. 그럼 다음번에는 권한에 대한 부분과 pass의 암호화에 대해서 알아보겠습니다.

 

 -Ayotera Lab-

 

 

기본적인 내용과 html에 대해서는 아래의 공식 사이트를 참조했습니다.

https://spring.io/guides/gs/securing-web/

 

Securing a Web Application

this guide is designed to get you productive as quickly as possible and using the latest Spring project releases and techniques as recommended by the Spring team

spring.io

댓글