Spring Security - form based authentication 구현

수하·2021년 9월 17일
3

Spring Security 공부

목록 보기
1/3

기존 프로젝트의 인증/인가 기능 쪽은 다른 개발자로부터 잘 구현된 상태로 이관받았기 때문에 볼 일이 없었다.
새로운 프로젝트에서 인증/인가 구현 방식을 cookie&session에서 jwt 방식으로 변경하고자 하여, spring security 공부를 시작하였다.

spring security 사용자 인증 원리

junhabaek님의 블로그 를 참고하여 공부하였고, 이 포스트에 작성하는 대부분의 내용은 junhabaek 님의 블로그를 참조한 내용이다.

로그인 구현 시 고려 사항

1. 최초 웹 인증 방법: 최초 사용자를 식별하기 위한 방법 (로그인)

  • Form based Authentication: html form 태그로 인증 정보 전송 (보통 SSR 수행 시 선택)
    Spring Security의 filter는 UsernamePasswordAuthenticationFilter이다.
  • Basic access Authentication: 아이디와 암호를 base64로 암호화한 후 request header에 전송 (API로 제공해야 할 때 사용)
    Spring Security의 Filter는 BasicAuthenticationFilter이다.

다른 인증방식들도 있지만, Spring Security에는 위 3가지 인증 방식에 대한 필터를 기본적으로 등록하고 있다. 때문에, 어떤 방식으로 인증이 들어오더라도 그 중 하나에 의해 인증 정보가 성공적으로 만들어진다면(isAuthenticated) 그 이후부터는, 공통적인 인증 객체를 이용해서 이어지는 인증/인가 작업들을 수행할 수 있다.
참고) Post request의 body로 전송하는 방법은 Spring Security filter를 직접 구현해야 함.

2. 인증의 유지 방법: 로그인 후 API 호출 시, 인증된 사용자인지 판단하는 방법

  • Cookie & Session (이번 포스트)
    1. 서버는 클라이언트의 로그인 요청에 대한 응답을 작성할 때, 인증 정보는 서버에 저장하고 클라이언트 식별자인 JSESSIONID를 응답 헤더의 set-cookie에 담는다
    2. 이후 클라이언트는 요청을 보낼 때마다, JSESSIONID 쿠키를 함께 보낸다.
    3. 서버는 JSESSIONID 유효성을 판별해 클라이언트를 식별.
  • JWT token (추후)
    1. 클라이언트 로그인 요청이 들어오면, 서버는 검증 후 클라이언트 고유 ID 등의 정보를 JWT의 Payload에 담는다.
    2. 인코딩된 Header와 Payload를 더한 뒤 비밀키로 해싱하여 JWT의 signiture 생성
    3. 클라이언트에 JWT 전달
    4. 클라이언트는 전달받은 토큰을 저장해두고, 서버에 요청할 때 마다 토큰을 요청 헤더 Authorization에 포함시켜 함께 전달
    5. 서버는 토큰의 Signature를 비밀키로 복호화한 다음, 위변조 여부 및 유효 기간 등을 확인하고, 유효한 토큰이면 응답

Spring Security의 인증 흐름

로그인 요청 흐름

이미지 및 내용 출처: junhabaek님의 블로그

전체 흐름

전반부


후반부

4.SecurityContextRepository로부터 기존 인증정보를 가져온 후, 이후의 필터들이 동작하도록 호출 (기존 세션 정보가 없었다면 빈 세션을 생성)
6. UsernamePasswordAuthenticationFilter가 수행되는데, 이 필터는 사용자가 보내온 request로부터 인증정보(사용자의 username과 password)를 포함하는 Authentication 객체의 일종인 UsernamePasswordAuthenticationToken을 생성. AuthenticationManager에게 이 token에 대한 인증을 요청하게 된다.
8. Provider중 UsernamePasswordAuthenticationToken을 처리할 수 있는 Provider는 DaoAuthenticationProvider이다. Token의 인증정보 중 username을 사용하여 현재 저장되어 있는 user를 UserDetailsService에 요청한다. 9-10번을 통해 유저를 받아온 후 password 일치 여부등, 유저를 검증하여 성공했다면 Authentication 객체를 반환한다.
9. Provider가 요청한 user를 받아오기 위해 loadByUsername 메서드 호출.
10. AccountRepository(직접 구현)에서 user를 받아옴

(이후) SecurityContextPersistenceFilter는 이후의 필터동작들이 끝난 후, 다시 자신의 실행흐름으로 돌아와, 인증 완료된 Authentication 객체가 존재할 경우, 이를 SecurityContextRepository에 저장한다.(기본 구현은 in memory)

세션 유지는 cookie를 이용한다. 사용자에게 sessionid가 set-cookie로 전달되므로, 사용자 브라우저에서는 이를 쿠키에 등록하고, 이후 요청할 때 쿠키와 함께 요청하게 된다.

form 기반 authentication 구현

전체 코드는 깃헙에..

springboot 프로젝트 설정

build.gradle 에 포함되어야 할 dependency는 다음과 같다.

  • spring security, thymeleaf, postgresql + jpa 사용을 위한 디펜던시들을 추가해준다.
  • 화면 개발 시 live reload를 위해 spring-boot-devtools를 추가한다.
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' 
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

application.yml 파일 설정은 다음과 같다.

  • postgresql 과 jpa 연동을 위한 DB 정보를 입력해준다.
  • resource live reload를 enable 시켜준다.
  • thymeleaf resource prefix로 template resource의 경로를 잡아준다.
  • static resource 경로도 같이 잡아준다.
spring:
  datasource:
    url: jdbc:postgresql://XXX
    username: XXX
    password: XXX
    driver-class-name: org.postgresql.Driver
  jpa:
    database: POSTGRESQL
    hibernate:
      ddl-auto: update

  devtools:
    livereload:
      enabled: true

  thymeleaf:
    cache: false
    prefix: file:src/main/resources/templates/
  web:
    resources:
      static-locations: file:src/main/resources/static

그리고 최종 디렉토리 구성은 아래와 같다.

회원가입 form과 회원가입 페이지 web controller 만들기

bootstrap 로그인 example 코드를 활용하여 회원가입 form을 만들었다. bootstrap 을 사용하기 위해 head와 body 하단에 cdn 추가를 해줬다.

아래는 사용자 아이디, 이름, 패스워드를 입력하여 POST /login요청을 하는 회원가입 form이다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-F3w7mX95PdgyTmZZMECAngseQB83DfGTowi0iMjiWaeVhAn4FJkqJByhZMI3AhiU" crossorigin="anonymous">
    <link th:href="@{/css/register.css}" rel="stylesheet"/>
    <title>회원가입</title>
</head>
<body class="text-center">
<main class="form-signin">
    <form action="/register" method="post">
        <h1 class="h3 mb-3 fw-normal">회원가입</h1>

        <div class="form-floating">
            <input type="text" class="form-control" id="userIdInput" name="userId" placeholder="ID">
            <label for="userIdInput">아이디</label>
        </div>
        <div class="form-floating">
            <input type="text" class="form-control" id="userNameInput" name="name" placeholder="Name">
            <label for="userNameInput">이름</label>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" id="passwordInput" name="password" placeholder="Password">
            <label for="passwordInput">Password</label>
        </div>

        <button class="mt-3 w-100 btn btn-lg btn-primary" type="submit">Sign Up</button>
    </form>
</main>


<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
        crossorigin="anonymous"></script>
</body>
</html>

submit 타입의 버튼을 클릭하면 <form> 태그 action에 적힌 url, http method로 form 내부 input 값들을 전달하게 된다. 값을 전송할 때 각 input 값들의 key는 <input> 태그의 name attribute 가 된다.
여기서는 userId, name, password 를 key로 하여 각각의 값을 전송하게 된다.


위 html 파일을 보여주기 위한 web controller는 다음과 같다.

@Controller
public class UserWebController {

	@GetMapping("/register")
	public String getRegisterForm() {
		return "register";
	}

}

이 때, 바로 실행을 하면 spring security 에서 기본적으로 제공하는 login 화면이 뜨기도 하고, static 파일에 대한 접근 권한이 없기 때문에 추후 css 파일 import 시 오류가 발생할 것이다.
따라서 SecurityConfig 파일을 만들어서 설정을 추가해주어야 한다.


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	private final AccountService accountService;
	private final PasswordEncoder passwordEncoder;

	@Override
	public void configure(WebSecurity web) throws Exception {
		// static 경로 접근 허용
		web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http.csrf().disable();

		// login 과 register API는 인증되지 않은 상태에서도 접근되어야 하므로 permitAll하고, 나머지는 모두 인증 후에 접근할 수 있도록 한다.
		http.authorizeRequests()
			.mvcMatchers("/login", "/register").permitAll()
			.anyRequest().authenticated()
		;

		// springboot 기본 login 화면 말고 직접 설정한 login form 사용
		// /login 경로에 직접 만든 login form이 있고, 로그인 성공 시 /index로 redirect 하는 설정이다.
		http.formLogin() 
			.loginPage("/login")
			.defaultSuccessUrl("/index")
			.permitAll();

	}
}

이제 어플리케이션을 실행시켜 /register 페이지에 접속하면 다음과 같은 화면이 뜬다.

사용자 repository, entity 만들기

회원가입 페이지를 만들었으니, 회원가입한 사용자 정보를 저장하기 위한 DB entity와 JPA repository를 만들 것이다.

먼저 사용자 정보를 저장하는 DB entity는 다음과 같다.


@Data
@Entity
@Table(name = "tbl_user_account")
public class Account {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column
	private String userId;

	@Column
	private String encryptedPassword;

	@Column
	private String name;

	@Column
	private String lastLogin;

	@Column
	private String role;

	public Account() {
	}

	@Builder
	public Account(String userId, String encryptedPassword, String name, String lastLogin, String role) {
		this.userId = userId;
		this.encryptedPassword = encryptedPassword;
		this.name = name;
		this.lastLogin = lastLogin;
		this.role = role;
	}
}
  • userId는 사용자 가입 ID이고, name은 실제 사용자의 이름이다.
  • DB에 비밀번호를 저장할 때는 암호화된 값을 저장해야 하기 때문에, 필드명을 password가 아닌 encrytedPassword 로 설정하였다. (개발 시 암호화되지 않은 비밀번호 저장 방지를 위한 최소한의 방어 장치이다)
  • role은 추후 authorization을 위해 만든 필드이다.

JPA에서는 단순히 Repository 인터페이스를 생성한후 JpaRepository을 상속받으면 기본적인 CRUD가 자동으로 생성된다. JPA 처리를 위한 repository 종류에는 아래와 같이 4가지 종류가 있지만, 나머지 3개 기능을 모두 사용 가능한 JpaRepository를 사용할 것이다.

  • Repository
  • CrudRepository
  • PagingAndSortingRepository
  • JpaRepository

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {

	@Query("select u from Account as u where u.userId=?1")
	Optional<Account> findByUserId(String userId);
}

사용자 ID로 사용자의 계정을 찾는 쿼리 및 메소드를 작성하였다. 이는 추후 login 작업에서 사용자 ID로 계정 정보를 가져와 비밀번호를 조회하는 로직을 만들때 사용될 예정이다.


비밀번호 암호화를 위한 PasswordEncoder 만들기

비밀번호 암호화 모듈은 config로 등록할 예정이다. 이 PasswordEncoder는 DB에 비밀번호를 저장할 때와, 로그인 시 입력한 비밀번호가 DB에 저장된 인코딩된 비밀번호와 일치하는지 판단할 때 사용된다.

인코더는 Spring Security의 BCrypt 해싱 함수를 사용하여 비밀번호를 암호화하는 BCryptPasswordEncoder를 사용할 것이다.

  • 비밀번호 저장이 목적이므로, 단방향 암호화 알고리즘을 사용해야 한다.
  • Bcrypt는 패스워드를 해싱할 때 내부적으로 랜덤한 솔트를 생성하기 때문에 같은 문자열에 대해서 다른 인코드된 결과를 반환한다. 따라서 미리 해쉬값을 계산해놓은 rainbow table로 비밀번호를 역계산할 수 없다.
    네이버D2 - 안전한 패스워드 저장 참고.
@Configuration
public class PasswordEncoderConfig {

	@Bean(name = "passwordEncoder")
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

}

BCryptPasswordEncoder를 bean으로 등록해놓고 사용할 것이다.


회원가입 controller, service 만들기

회원가입 페이지에서 Sign Up 버튼을 눌렀을 때 회원가입 백엔드 처리 로직을 위해 회원가입 controller와 service를 만들어야 한다.

먼저 회원가입 service 는 아래와 같다. 중요한 점은 Spring Security의 UserDetailsService 를 implement 해야 한다는 점이다. 추후 login 시 사용되는 UserDetailsService의 메소드를 override 하기 위함이다.


@Service
@RequiredArgsConstructor
public class AccountService implements UserDetailsService {
	private final AccountRepository repository;
	private final PasswordEncoder passwordEncoder;

	// controller 부터 받은 값을 repository를 통해 DB에 저장
	public void createUserAccount(UserRegisterDto userRegisterDto) {
		this.repository.save(
			Account.builder()
				.userId(userRegisterDto.getUserId())
				.name(userRegisterDto.getName())
				.encryptedPassword(passwordEncoder.encode(userRegisterDto.getPassword()))
				.role(userRegisterDto.getRole())
				.build());
	}
}
  • encryptedPassword: 위에서 설정한 PasswordEncoder로 비밀번호를 인코딩해서 저장해야 한다.
  • role: 이 포스트에서는 사용되지 않지만 일단 저장하는 코드만 작성해보았다.

이제 register API를 만들 차례이다. RestController을 새로 생성한 다음, AccountService를 주입시키고 register API를 만들어 AccountService의 createUserAccount)와 연결시켰다.

@RestController
@RequiredArgsConstructor
public class AuthenticationController {

	private final AccountService accountService;

	@PostMapping("/register")
	public void register(UserRegisterDto registerDto) {

		this.accountService.createUserAccount(registerDto);
	}

}

이제 회원가입 페이지에서 회원 정보를 입력하고 저장하면, DB에서 새로 생성된 회원 정보를 조회할 수 있다.


로그인 form 과 로그인 web controller

로그인 폼은 회원가입 폼을 사용하여 만들었다. 단, 차이점이 있다면 로그인 시도 결과 오류가 발생하면 params에 error 라고 넘어오기 때문에, thymeleaf에서 오류 발생 시 오류 멘트를 보여주는 로직을 추가하였다.

<main class="form-signin">
    <form action="/login" method="post">
        <h1 class="h3 mb-3 fw-normal">로그인</h1>

        <div class="form-floating">
            <input type="text" class="form-control" id="floatingInput" name="username" placeholder="Name">
            <label for="floatingInput">User name</label>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" id="floatingPassword" name="password" placeholder="Password">
            <label for="floatingPassword">Password</label>
        </div>

        <div th:if="${param.error}" style="color: red">
            Invalid user name and password.
        </div>

        <button class="mt-3 w-100 btn btn-lg btn-primary" type="submit">Sign Ip</button>
    </form>
</main>

위 html을 보여주기 위한 web controller는 아래와 같다. 회원가입 페이지를 보여주기 위한 UserWebController에서 login 메소드를 추가하였다.

@Controller
public class UserWebController {

	@GetMapping("/register")
	public String getRegisterForm() {
		return "register";
	}

	@GetMapping("/login")
	public String getloginForm() {
		return "login";
	}
}

로그인 시도 후 에러가 발생한 화면이다.
로그인 form 아래에 빨간 글씨로 Invalid user name and password라고 써있는 것을 확인할 수 있다.


로그인 처리

로그인 화면에서 Sign In 버튼을 클릭하면, <form> 태그의 정보에 맞게 /login으로 POST 요청을 하게 된다. 그런데 로그인 처리를 위한 controller를 별도로 만들지 않는다. 모든건 Spring Security가 처리해준다. 우리가 해야 할 것은 SecurityConfig를 적절하게 설정해주고, 사용자 관리 서비스인 AccountService에서 loadUserByUsername 메소드를 override해주는 것밖에 없다.

먼저, AccountService에서 loadUserByUsername 메소드를 override 해줘야 한다.
AccountService가 implement 하고 있는 UserDetailsService는 UsernamePasswordAuthenticationFilter의 authentication provider인 DaoAuthenticationProvider 에서 사용자 정보를 가져올 때 사용하는 interface이다. 로그인 흐름 참조


	@Override
	public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
		Optional<Account> userAccountOptional = repository.findByUserId(userId);
		if (!userAccountOptional.isPresent()) {
			throw new UsernameNotFoundException(userId);
		}

		Account userAccount = userAccountOptional.get();
		return User.builder()
			.username(userAccount.getName())
			.password(userAccount.getEncryptedPassword())
			.roles("ROLE")
			.build();

	}

사용자가 입력한 userId를 받아서, DB에서 사용자를 조회하고, 없으면 UsernameNotFoundException을 발생시키고, 있으면 User 객체에 담아서 반환한다. User 객체는 spring security에서 UserDetails IF를 구현한 구현체이다.

그리고 SecurityConfig 에서 userDetailsSerivce 의 구현체로 우리가 만든 AccountService를 등록하고, 사용자가 form에 입력한 패스워드를 DB에 암호화되어 저장된 값과 비교하기 위해 passwordEncoder를 등록해줘야 한다.


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	private final AccountService accountService;
	private final PasswordEncoder passwordEncoder;

	@Override
	public void configure(WebSecurity web) throws Exception {
		...
	}

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		// spring-security의 userDetailService와 AccountService를 연동하고 passwordEncoder 설정
		auth.userDetailsService(accountService).passwordEncoder(passwordEncoder);
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http.csrf().disable();

		// login, register 요청만 모두 허용하고, 나머지 요청은 인증된 사용자만 접근 가능
		http.authorizeRequests()
			.mvcMatchers("/login", "/register").permitAll()
			.anyRequest().authenticated()
		;

		// 로그인 페이지는 /login이며, 성공했을때 /index로 이동한다.
		http.formLogin()
			.loginPage("/login")
			.defaultSuccessUrl("/index", true);

		http.logout()
			.logoutUrl("/logout")
			.logoutSuccessUrl("/");
	}
}

테스트용 화면과 API 만들기

로그인이 잘 되는지 테스트할 화면과 API를 만들어야 한다.

로그인한 후 이동할 index 화면이다. 로그인 성공 멘트와 logout 버튼이 있다. index 화면을 보여줄 web controller는 login, register 화면과 동일하니 참고하여 만들자.

<div>
    <h1>메인화면입니다~ 로그인성공~</h1>
    <form action="/logout" method="post">
        <input type="submit" value="Logout"/>
    </form>
</div>

로그인 테스트용 API이다. 로그인이 잘 됐으면 Success 메시지를 반환할 것이고, 아니면 login 페이지로 리디렉션될 것이다.

@RestController
@RequestMapping("/api/v0.1/test")
@Slf4j
public class TestController {

	@GetMapping("/general")
	public String general() {
		log.info("general");
		return "Success - general";
	}

	@GetMapping("/admin")
	public String admin() {
		log.info("admin");
		return "Success - admin";
	}
}

테스트

테스트 케이스는 아래와 같다.

  1. 로그인 없이 /index, /api/v0.1/test/general, /api/v0.1/test/admin 요청하기
    모두 login 페이지로 리디렉션된다.

  2. 회원가입

  3. 로그인
    자동 /index 로 리디렉션된다.

  4. 로그인 후 /index, /api/v0.1/test/general, /api/v0.1/test/admin 요청하기
    /index 요청 시 아래와 같이 로그인 성공 메시지와 로그아웃 버튼이 잘 보인다.

    /api/v0.1/test/general 요청 시 성공 메시지가 잘 보인다.

  5. 로그아웃
    로그인 페이지로 리디렉션된다.

  6. 로그아웃 후 /index, /api/v0.1/test/general, /api/v0.1/test/admin 요청하기
    모두 login 페이지로 리디렉션된다.



Springboot 에서 spring security를 활용하여 form 기반 authentication을 구현해보았다.
다음 시간에는 spring security를 활용하여 사용자 authorization 을 해볼 것이다.

2개의 댓글

comment-user-thumbnail
2021년 9월 17일

완전 개발자같아요 멋있어요..

1개의 답글