spring boot: study.diary : Spring Security 폼 로그인

diary 프로그램에 로그인 기능이 없어서 아무나 접근해서 읽기를 볼 수도 있고, 쓸 수도 있는 상황이야.

spring boot 의 Spring Security 기능을 이용해서 폼 로그인 기능을 붙여볼께.

https://start.spring.io 에서 Add Dependencies 로 security 를 검색하여 Spring Security 의존성을 추가해.


Explorer 버튼으로 수정된 build.gradle 파일의 내용에서 spring security 의존성 부분을 복사해서 프로젝트의 build.gradle 에 붙여넣기 한다.

build.gradle 변경된 것을 적용한다.

이 상태에서 실행시켜서 웹브라우저에 http://localhost:8080 으로 입력해보면 http://localhost:8080/login 으로 이동해서 로그인 화면이 표시가 된다.

서버 로그를 보면 패스워드가 표시가 되어 있어.

Username 에 user 를, Password 에는 로그에 표시된 패스워드값을 복사해서 붙여넣기하고 Sign in 버튼을 눌러보면 정상적으로 홈 화면이 표시되는걸 확인할 수 있지.


심지어 인증되지 않은 상태에서는 Rest API 호출도 401 Unauthorized 에러를 리턴하게 되지.


이것으로 build.gradle 에 추가한 implementation ‘org.springframework.boot:spring-boot-starter-security’ 이 한 줄이 내부적으로 인증과 관련된 무언가를 자동으로 해주게 된다는걸 알 수가 있어.

인증되지 않은 경우 인증을 위한 로그인 화면으로 이동시켜주고, 인증이 된 경우에만 endpoint 에 접근할 수 있도록 처리해 주는거지. 하지만, 기본적인 뼈대만 구성해준 것이지, 인증 로직은 우리가 직접 구현을 해 주어야 해.

이제 자동으로 무언가를 처리하고 있는 부분에 조금 관여를 해보자.

config 패키지를 새로 만들고 이 패키지에 SecurityConfig 클래스를 생성해볼께.

그리고 이 클래스에 @Configuration 과 @EnableWebSecurity 애노테이션을 추가해줘.

package com.woohahaapps.study.diary.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

}

이 클래스는 인증과 관련된 설정을 담당하게 될 클래스야. 이 클래스 안에 @Bean 이라는 애노테이션을 붙인 메소드를 추가하는 과정이 필요해.

spring boot 이전 버전에서는 WebSecurityConfigurerAdapter 클래스를 상속받아서 오버라이드 메소드를 만들었지만 WebSecurityConfigurerAdapter 클래스가 Deprecated 되어서(Spring Security 5.7.0-M2부터) 최신 버전의 spring boot 에서는 사용할 수가 없어.

가장 중요한 메소드는 SecurityFilterChain 을 리턴하는 메소드야.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity.build();
    }
}

Security 를 직접 관리하기 위해서 추가한 SecurityConfig 클래스이기 때문에 @Configuration 애노테이션을 붙인 것이고, 이 안에서 직접 메소드를 정의하는 경우이기 때문에 @Bean 애노테이션을 붙여준거야.

HttpSecurity 클래스는 Builder 패턴이 적용되어 있는 클래스이기 때문에 설정을 모두 마치고 난 다음에 build() 메소드로 리턴을 해주고 있어. 이 상태로 프로그램을 빌드해서 실행시켜주면 아무런 설정도 하지 않은 상태이기 때문에 인증없이 모든 화면을 볼 수가 있게 돼.

HttpSecurity 클래스를 살펴보면 굉장히 많은 메소드들이 정의가 되어 있는걸 알 수 있어.
참고 : https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/builders/HttpSecurity.html

이제 HttpSecurity 클래스형 객체에 설정들을 추가해볼께.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated()
                )
                .build();
    }

authorizeHttpRequests 는 인증 사용에 대한 설정이야. 파라미터로 anyRequest().authenticated() 를 사용하면 모든 페이지에 대해서 인증받은 경우만 사용할 수 있게 한다는 설정이야.

이 상태로 프로그램을 실행시켜보면 아까 봤던 로그인 페이지는 보이지 않고, 페이지를 볼 수 있는 권한이 없다는 403 에러가 표시되지. 인증되지 않았기 때문이야.

이제 Spring Security 가 제공하는 기본 로그인폼을 사용하는 설정을 추가해볼께.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated()
                )
                .formLogin(Customizer.withDefaults())
                .build();
    }

formLogin(Customizer.withDefaults()) 는 기본 제공되는 폼 양식을 사용하는 설정이야. 개발자가 직접 디자인한 로그인폼을 사용하려면 아래와 같이 설정하면 돼.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                )
                .build();
    }

프로그램을 실행시켜보면 /login 경로로 이동은 했지만, 아무것도 표시가 되지 않고 있지?

login 경로에 대한 핸들러를 작성하지 않았기 때문이지.

간단하게 bootstrap 을 이용해서 login 페이지를 만들고, /login 경로에 대한 핸들러를 작성해볼께.

templates/login.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">

    <!-- 모바일에서의 적절한 반응형 동작을 위해 -->
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- -->


    <title>Title</title>

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
          rel="stylesheet"
          integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
          crossorigin="anonymous"
    >
    <!-- -->

    <!-- head 태그에 다음의 코드를 삽입 -->
    <style type="text/css">

        html,
        body {
            height: 100%;
        }

        .form-signin {
            max-width: 330px;
            padding: 1rem;
        }

        .form-signin .form-floating:focus-within {
            z-index: 2;
        }

        .form-signin input[type="email"] {
            margin-bottom: -1px;
            border-bottom-right-radius: 0;
            border-bottom-left-radius: 0;
        }

        .form-signin input[type="password"] {
            margin-bottom: 10px;
            border-top-left-radius: 0;
            border-top-right-radius: 0;
        }

    </style>

</head>
<body>

<main class="form-signin w-100 m-auto">
    <form>
        <h1 class="h3 mb-3 fw-normal">Please sign in</h1>

        <div class="form-floating">
            <input type="email" class="form-control" id="floatingInput" placeholder="name@example.com">
            <label for="floatingInput">Email address</label>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" id="floatingPassword" placeholder="Password">
            <label for="floatingPassword">Password</label>
        </div>

        <div class="form-check text-start my-3">
            <input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
            <label class="form-check-label" for="flexCheckDefault">
                Remember me
            </label>
        </div>
        <button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
        <p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
    </form>
</main>

<!-- Popper -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
        integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
        crossorigin="anonymous"></script>

<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
        integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
        crossorigin="anonymous"></script>

</body>
</html>
LoginController.java
package com.woohahaapps.study.diary.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

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

프로그램을 실행시켰는데, 아직도 http://localhost:8080/login 페이지는 표시가 되질 않네.

이유는 해당 URL 에 대해서도 인증필요 상태이기 때문이야.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .permitAll()
                )
                .build();
    }

permitAll() 을 추가해주어야만 login 경로에 대해서 인증되지 않은 상태에서 접근이 가능해지지.

이제 사용자가 Email address 와 Password 를 입력하고 Sign in 버튼을 클릭했을 때 입력받은 데이터를 가지고 로그인 가능 여부를 판단하는 로직을 구현해볼께.

login.html 파일의 form 태그에 입력데이터를 처리할 url 을 명시해줄께.

login.html
<main class="form-signin w-100 m-auto">
    <form th:action="@{/process_login}" method="POST">
        <h1 class="h3 mb-3 fw-normal">Please sign in</h1>

        <div class="form-floating">
            <input type="email" class="form-control" id="floatingInput" name="email" placeholder="name@example.com">
            <label for="floatingInput">Email address</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 class="form-check text-start my-3">
            <input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
            <label class="form-check-label" for="flexCheckDefault">
                Remember me
            </label>
        </div>
        <button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
        <p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
    </form>
</main>

Sign in 버튼을 클릭했을 때 데이터를 처리하기 위해서 호출하는 action url 을 @{/process_login} 으로 설정하고 method 는 POST 를 설정했어.

그리고 email 주소와 password 태그에 name 속성을 지정했어.

이제 Sign in 버튼을 클릭했을 때 데이터를 처리할 /process_login URL 핸들러를 만들어야 하는데, 컨트롤러 클래스에 Post Action 에 대한 핸들러를 추가하는 것이 아니라, UserDetailsService 인터페이스에 대한 구현 클래스를 추가하는 방법이어야 해.

service 패키지 아래에 LoginService 라는 이름의 클래스를 만들고 UserDetailsService 인터페이스의 구현 클래스로 선언해보자.

LoginService.java
package com.woohahaapps.study.diary.service;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class LoginService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return null;
    }
}

loadUserByUsername 함수는 UserDetailsService 인터페이스의 메소드를 오버라이드한 메소드야.

이 함수가 파라미터로 받는 username 이 로그인폼에서 전달한 값이 되는거야.

SecurityConfig 클래스의 속성 설정에 /process_login url 에 대한 속성을 추가해볼께.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/process_login").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .loginProcessingUrl("/process_login")
                        .permitAll()
                )
                .build();
    }

loginProcessingUrl 함수를 추가하고 파라미터로 로그인폼이 실행될 url 을 기록했어.

만약 위쪽의 requestMatchers(“/process_login”).permitAll() 을 추가하지 않는다면, anyRequest().authenticated() 에 의해서 /process_login URL 역시 인증된 상태에서만 호출되도록 설정되기 때문에 또다시 /login 페이지로 이동되겠지.

LoginService 클래스의 loadUserByUsername 함수에 BreakPoint 를 걸어두고 디버그모드로 실행해보면 파라미터 username 에 아무값도 들어오지 않는걸 확인할 수 있어.


로그인폼에서 이메일주소 입력박스의 name 이 email 이기 때문에 이 값이 제대로 전달되지 않은거니까, 이걸 수정해볼께.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/process_login").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .loginProcessingUrl("/process_login")
                        .usernameParameter("email")
                        .permitAll()
                )
                .build();
    }

다시 디버그모드로 실행해보면 로그인폼의 이메일주소로 입력한 값이 전달되고 있는 걸 확인할 수가 있지.

이제 로그인에 필요한 로직을 임시로 구현해볼께.

loadUserByUsername 함수가 리턴하는 UserDetails 은 interface 로 정의가 되어 있는데, 로그인 처리에 필요한 적절한 클래스로 구현한 객체를 리턴할 수 있도록 구성해보자.

가장 먼저 데이터베이스에서 관리되는 사용자정보에 매핑되는 domain 클래스 Member 를 생성해볼께.

package com.woohahaapps.study.diary.domain;

import lombok.Data;

@Data
public class Member {
    private String email;
    private String password;
}

지금은 단순하게 이메일주소와 비밀번호만 저장하고 있어.

그리고 UserDetails 인터페이스를 구현한 User 클래스를 생성해볼께.

package com.woohahaapps.study.diary.domain;

import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@Data
public class User implements UserDetails {

    private Member member;

    public User(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

UserDetails 인터페이스를 구현하고, 메소드를 오버라이드했어.

그리고 Member 클래스형 객체를 선언해서 생성자를 통해서 주입받도록 했지.

LoginService 의 loadUserByUsername 함수는 아래와 같이 임시적인 코드로 작성했어.

@Service
public class LoginService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username.equals("woohaha@gmail.com")) {
            return new User(new Member("woohaha@gmail.com", "1111"));
        }
        return null;
    }
}

전달받은 파라미터값이 woohaha@gmail.com 인 경우에만 정상적인 사용자데이터(로그인 성공)로 처리하고, 나머지는 비정상적인 데이터(로그인 실패)로 처리하는거지.

이 상태에서 프로그램을 빌드하고 실행시켜볼께.

로그인성공을 예상하는 woohaha@gmail.com 을 입력했는데, 아래와 같은 오류가 터지고 있지.

아직 비밀번호 처리에 대한 코드가 작성되지 않았기 때문이야.

SecurytyConfig 에 passwordEncoder 라는 메소드를 Bean 으로 작성해줄께.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    ...
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

이제 로그인폼에 입력한 패스워드 값이 BCryptPasswordEncoder 에 의해서 암호화되고, 사용자정보에 포함된 패스워드값이 BCryptPasswordEncoder 에 의해서 암호화해제가 되도록 설정되었어.

이 상태에서 다시 프로그램을 빌드해서 실행해보면 아래와 같은 로그를 볼 수가 있어.

2024-03-02T17:22:51.830+09:00  WARN 5092 --- [nio-8080-exec-6] o.s.s.c.bcrypt.BCryptPasswordEncoder     : Encoded password does not look like BCrypt

LoginService 의 loadUserByUsername 함수가 전달하고 있는 패스워드가 평문인 “1111” 이라서 이 값이 BCrypt 에 의해서 암호화된 것으로 보이지 않는다는 의미야.

평문인 “1111” 을 강제로 암호화해서 전달해볼께.

@Service
public class LoginService implements UserDetailsService {

    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username.equals("woohaha@gmail.com")) {
            return new User(new Member("woohaha@gmail.com", passwordEncoder.encode("1111")));
        }
        return null;
    }
}

이제 프로그램을 실행시켜서 email 주소로 woohaha@gmail.co, Password 값으로 1111 을 입력하면 로그인이 성공 처리되고 home 화면이 표시가 될거야.

남은 작업

이제 남은 작업은 사용자 목록을 관리하기 위한 테이블을 만들고, LoginService 에서 해당 테이블에서 데이터를 추출해서 넘기는 로직의 코드를 구현하는 일이야.

Leave a Comment