처음부터 차근차근

Spring security + thymeleaf 이용해서 로그인 구현하기 본문

Framework/Spring

Spring security + thymeleaf 이용해서 로그인 구현하기

_soyoung 2022. 11. 2. 12:22
반응형

spring security

spring security는 인증과 인가 등의 보안 기능을 제공하는 스프링 프로젝트이다.
spring에서 제공하는 강력한 보안 기능을 이용해서 안전한 웹 애플리케이션을 손쉽게 개발할 수 있다.

인증과 인가의 차이

인증(Authentication)

사용자가 누군지 확인하는 것이다.
대표적인 예로 로그인이 있다.

인가(Authorization)

권한을 확인하는 것이다.
인증을 통해 검증된 사용자가 웹 애플리케이션의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권한이 있는지 체크하는 것을 말한다.
대표적인 예로 admin 페이지에 일반 user가 들어오지 못하는 것을 들 수 있다.

principal?

spring security를 사용하면 view에서 사용자 현재 로그인한 사용자 이름 같은 걸 출력하거나 현재 로그인한 사용자를 정보를 불러올 때 principal이라는 키워드를 사용할 것이다.principal이 뭐냐면 기능을 사용하는 주체이다.그래서 principal(접근 주체)는 사용자가 될 수 도 있고, 디바이스, 시스템 등이 될 수도 있다.

로그인, 로그아웃 구현하기

스프링 시큐리티에서는 로그아웃과 로그인 기능이 이미 만들어져 있어서 설정 몇 번만 하면 로그인 기능을 사용할 수 있다.로그인 로그아웃 기능을 구현하는데는 2가지 방법이 있는것으로 아는데

1. UserDetail 사용하는 방법 : form 로그인
2. OAuth2 : 소셜 미디어 로그인

필자는 폼으로 로그인을 구현했기 때문에 UserDetail 방법을 사용했다.

동작원리

https://lotuus.tistory.com/78

[Spring Security] 동작방법 및 Form, OAuth 로그인하기 (Feat.Thymeleaf 타임리프)

목차 Spring Security란? Spring을 사용할 때 애플리케이션에 대한 인증, 권한 부여 등의 보안 기능을 제공하는 프레임워크이다. 다양한 로그인 방법(Form태그, OAuth2, JWT...)에 대해 Spring이 어느정도 구현

lotuus.tistory.com

동작원리에 대해서 이분이 잘 설명해두셔서 많이 도움됐다.

1. 아이디, 비밀번호를 가진 요청이 들어옴
2. Form 로그인이면 UserDetailsService의 loadUserByUsername메서드가 실행되고
OAuth2 로그인이면 OAuth2UserService의 loadUserByUsername메서드가 실행
3. loadUserByUsername 메서드는 회원을 찾는 메서드이다.
그래서 loadUserByUsername에다 회원을 찾아주는 로직을 구현하면된다.
4. 이때, 회원정보는 Form 로그인이면 UserDetails타입으로, OAuth2 로그인이면 OAuth2User타입으로 반환
(그래서 loadUserByUsername 메서드 반환 타입이 UserDetails 또는 OAuth2User임.)
5. UserDetails 또는 OAuth2User를 반환하면 Spring에서 알아서 Session에 저장해줌

구현

1. 먼저 config 파일을 생성한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final LoginFailureHandler loginFailureHandler;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                    .antMatchers("/noticeboard/**").authenticated() // 인증 요구
                    .antMatchers("/admin/**").hasRole("ADMIN") // admin만 가능
                    .anyRequest().permitAll() // 인증없이 사용가능
                    .and()
                .formLogin()
                    .loginPage("/member/signinform") // 로그인 페이지
                    .usernameParameter("email")	// 로그인 시 form에서 가져올 id name 값
                    .passwordParameter("pw") // 로그인 시 form에서 가져올 password name 값
                    .loginProcessingUrl("/member/signin") // 로그인을 처리할 URL 입력
                    .defaultSuccessUrl("/hanbok/main")	// 로그인 성공시 url
                    .failureHandler(loginFailureHandler) // 로그인 실패 핸들러
                    .permitAll()
                    .and()
                .logout()
                    .permitAll()
                    .logoutUrl("/member/signout") // 로그아웃을 처리할 URL 입력
                    .invalidateHttpSession(true) // 세션 무효화
                    .logoutSuccessHandler(new LogoutSuccessHandler() { // 로그아웃 성공 시 handler
                        @Override
                        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                            response.sendRedirect("/hanbok/main"); // 이동
                        }
                    })
                    .deleteCookies("JSESSIONID", "remember-me"); // 쿠키 삭제

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() { // 계속 사용하니까 bean으로 만들어 놓음
        return new BCryptPasswordEncoder();
    }
}

로그인 form에서 가져오는 name값의 default값이
ID는 'username',
password는 'password' 이다.
필자의 경우 태그의 name값을 바꾸고 싶어서 따로 설정했는데 바꾸고 싶지 않으면 설정을 지우고, name값 저걸로 맞추면 된다.

그리고, 로그인과 로그아웃 처리 url도 default값이 있다.
로그인 : /login
로그아웃 : /logout
위의 name값과 마찬가지로 바꾸고 싶지 않으면 나중에 url 부분에 값을 저걸로 맞춘다.


2. 로그인 실패 handler 생성
LoginFailureHandler.class

@Component
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {
        String errorMessage;

        if(exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 맞지 않습니다.";
        } else if (exception instanceof InternalAuthenticationServiceException) {
            errorMessage = "내부 시스템 문제로 로그인 요청을 처리할 수 없습니다.";
        } else if (exception instanceof UsernameNotFoundException) {
            errorMessage = "존재하지 않는 계정입니다.";
        } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
            errorMessage = "인증 요청이 거부되었습니다.";
        } else {
            errorMessage = "알 수 없는 오류로 로그인 요청을 처리할 수 없습니다.";
        }

        // 세션 처리
        HttpSession session = request.getSession();
        session.setAttribute("errorMessage", errorMessage);

        // 이동
        setDefaultFailureUrl("/member/signinform");
        super.onAuthenticationFailure(request, response, exception);
    }
}

코드출처 :
https://dev-coco.tistory.com/126

Spring Boot 게시판 Security 로그인 실패시 메시지 출력하기

로그인 화면을 커스터마이징 했지만 부족한 부분들이 있다. 만약 로그인 페이지에서 잘못된 로그인 정보를 입력하면, 로그인에 실패했지만 아무런 메시지를 보지 못하고 로그인 페이지만 재로

dev-coco.tistory.com

로그인 실패 핸들러는 이 분이 잘 만들어두셔서 가져와서 사용했다.
구글링해봤더니 error message를 위 블로그 처럼 get 방식으로 넘겨서 처리하는 사람들이 많았는데, 그렇게 해보니까 아래 이미지처럼 url 창이 안예뻐서

url

session으로 처리하고 나중에 contorller가서 session 정보 가져온다음 바로 삭제하는 방식으로 사용했다.

@GetMapping("/signin-form")
public String getLoginform(HttpSession session, Model model) {
    if (session.getAttribute("errorMessage") != null) {
        model.addAttribute("error",true);
        model.addAttribute("errorMessage", session.getAttribute("errorMessage"));
        session.removeAttribute("errorMessage");
    }

    return "/member/signin-form"; // view
}

이렇게!

3. domain에다 UserDeatils interface 구현

public class Member implements UserDetails {
    private Long id;
    private String email;
	...
    
    // 사용자 권한 가져오는 메소드
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { 
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return role.toString(); // 이 부분 수정해서 사용하세요
            }
        });
        return collect;
    }

    // 비밀번호 리턴
    @Override
    public String getPassword() {
        return getPw();
    }

    // 로그인할 때 ID값 리턴 : email로 로그인하기 때문에 email 리턴
    @Override
    public String getUsername() {
        return getEmail();
    }

    // 계정 만료 여부
    // true : 만료안됨
    // false : 만료됨
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정 잠김 여부
    // true : 잠기지 않음
    // false : 잠김
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 계정 비밀번호 만료 여부
    // true : 만료되지 않음
    // false : 만료됨
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정 활성화 여부
    // true : 활성화 됨
    // false : 비활성화 됨
    @Override
    public boolean isEnabled() {
        return true;
    }
}

override해야되는 메소드들이 있을텐데, override해준다.


4. 서비스 class에다 UserDetailsService interface 구현, loadUserByUsername override

@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {

    private final MemberRepository memberRepository;

    // 회원 찾는 로직
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        MemberEntity member = memberRepository.findByEmail(email);
        if(member == null) {
            throw new UsernameNotFoundException("");
        }
        return Member.builder()
                .id(member.getId())
                .email(member.getEmail())
                .pw(member.getPw())
                .name(member.getName())
                .role(member.getRole())
                .build();
    }
}

원래있는 서비스 클래스에다 interface를 구현해도 되지만 필자의 경우 걍 따로 뺐다.member를 username으로 찾아봤는데도 없으면 throw로 예외를 날려서 예외처리 해줘야한다. (이것때문에 오류났었음)


5. html 파일(프론트) 작성
html 제일 상단에다 두 xmlns를 추가해준다.
그래야 thymeleaf랑 security를 view단에서 사용할 수 있다.

<html lang="en" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:th="http://www.thymeleaf.org">


로그인 (틀만 참고하기)

<form th:action="@{/member/signin}" method="post">
    <div class="input-field">
        <input type="text" name="email" placeholder="이메일 입력" required>
        <i class="uil uil-envelope icon"></i>
    </div>
    <div class="input-field">
        <input type="password" class="password" name="pw" placeholder="비밀번호 입력" required>
        <i class="uil uil-lock icon"></i>
        <i class="uil uil-eye-slash showHidePw"></i>
    </div>
    <div class="input-field button">
        <input type="submit" value="Login">
    </div>
</form>

위 설정에서 지정한 로그인 처리 url을 꼭 맞춰줘야하고, 반드시 th:action="@{}"을 사용해서 감싸줘야한다.
내부적으로 로그인할 때 스프링시큐리티에서 csrf 검증을 하기 때문에 저게 없으면 csrf 토큰이 발급받아지지 않아서 기능이 실행되지 않는다.
그리고 form에서 가져올 id, password name 값 지정한 것도 꼭 맞춰줘야하고, 반드시 post method로 지정해줘야 한다.


로그아웃 (틀만 참고하기)

<form th:action="@{/member/signout}" method="POST" sec:authorize="isAuthenticated()" name="deleteform">
    <a sec:authorize="isAuthenticated()" href="javascript:deleteform.submit()">로그아웃</a>
</form>

로그인 할 때랑 마찬가지로 지정한 url 꼭 맞춰야하고, post 매핑해줘야한다.


반응형
Comments