▼ Backend/스프링 (Spring)

Spring Boot | 로그인 구현하기 (Spring Security)

Valar 2021. 11. 17. 16:04
반응형

[스프링 부트 (Spring Boot)/게시판 만들기] - 1 | 스프링 부트 프로젝트 만들기

위의 과정을 통해 생성된 프로젝트입니다.

 

구성환경

SpringBoot, Gradle, Thymeleaf, Jpa(JPQL), Jar, MariaDB

 

이번 장에서는 스프링 시큐리티(Spring Security)를 이용한 로그인 처리, 로그아웃, 중복 로그인 처리, 로그인 정보 유지, 예외처리 등을 적용해본다.

 

회원 테이블 생성하기


로그인 처리 테스트를 하기 위한 기본적인 컬럼만 생성하였으니 필요시 추가하여 사용한다.

 

CREATE TABLE `member` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '시퀀스',
    `email` VARCHAR(200) NOT NULL COMMENT '이메일' COLLATE 'utf8mb4_general_ci',
    `pwd` VARCHAR(200) NOT NULL COMMENT '패스워드' COLLATE 'utf8mb4_general_ci',
    `last_login_time` DATETIME NULL DEFAULT NULL COMMENT '마지막 로그인 시간',
    `register_time` DATETIME NULL DEFAULT NULL COMMENT '등록일',
    `update_time` DATETIME NULL DEFAULT NULL COMMENT '수정일',
    PRIMARY KEY (`id`) USING BTREE,
    UNIQUE INDEX `email` (`email`) USING BTREE
)
COMMENT='회원'

 

build.gradle

spring-boot-starter-security 추가

 

implementation 'org.springframework.boot:spring-boot-starter-security'

 

로그인 HTML

 login.html (/src/main/resources/templates/member) 

 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorator="layout/default_layout">
<!-- layout Content -->
<th:block layout:fragment="content">
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-lg-5">
                <div class="card shadow-lg border-0 rounded-lg mt-5">
                    <div class="card-header">
                        <h3 class="text-center font-weight-bold my-4">Login</h3>
                    </div>
                    <div class="card-body">
                        <form id="frm" name="frm" action="/login/action" method="post">
                            <!-- <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> -->
                            <div th:if="${param.error}">
                                <p th:text="${exception}" class="alert alert-danger"></p>
                            </div>
                            <div class="form-floating mb-3">
                                <input class="form-control" id="username" name="username" type="email" placeholder="name@example.com" required />
                            </div>
                            <div class="form-floating mb-3">
                                <input class="form-control" id="password" name="password" type="password" placeholder="Password" required />
                            </div>
                            <div class="form-check mb-3">
                                <input class="form-check-input" id="remember-me" name="remember-me" type="checkbox" />
                                <label class="form-check-label" for="remember-me">Remember Password</label>
                            </div>
                            <div class="d-flex align-items-center justify-content-between mt-4 mb-0">
                                <!-- <a class="small" href="javascript:">Forgot Password?</a> -->
                                <a class="btn btn-primary" href="javascript:fnSubmit();">Login</a>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script th:inline="javascript">
        let frm = $("#frm");

        function fnSubmit() {
            frm.submit();
        }

        $(function() {
            $("#password").on("keyup", function(e) {
                if (e.key == "Enter") fnSubmit();
            });

            frm.validate({
                submitHandler: function(form) {
                    // Submit Action..
                    form.submit();
                }
            });
        });
    </script>
</th:block>
</html>

 

로그인 HTML에서 이메일, 비밀번호의 name을 username, password로 설정하였는데, 이는 스프링 시큐리티를 통한 로그인 처리 시 FormLoginConfigurer에서 디폴트 값으로 username, password으로 받기 때문이다.

FormLoginConfigurer에서 파라미터를 username, password로 받고 있다.

 

*변경하고 싶을 경우 SecurityConfig에서 아래와 같이 직접 설정해준다.

 

.formLogin() 
.usernameParameter("user") // 디폴트가 username
.passwordParameter("pwd") // 디폴트가 password

 

엔티티(Entity)

  Member.java  


Member Entity를 생성하고, UserDetails를 상속받아 로그인 처리 시 필요한 메서드를 오버라이드 한다.

 

package com.board.study.entity.board.member;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.board.study.entity.BaseTimeEntity;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;

@EqualsAndHashCode( of = {"id"}) // equals, hashCode 자동 생성
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Member extends BaseTimeEntity implements UserDetails {

    private static final long serialVersionUID = 1 L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String pwd;
    private LocalDateTime lastLoginTime;

    @Builder
    public Member(Long id, String email, String pwd, LocalDateTime lastLoginTime) {
        this.id = id;
        this.email = email;
        this.pwd = pwd;
        this.lastLoginTime = lastLoginTime;
    }

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

    @Override
    public String getUsername() {
        return this.email;
    }

    //계정이 갖고있는 권한 목록은 리턴
    @Override
    public Collection <? extends GrantedAuthority > getAuthorities() {

        Collection < GrantedAuthority > collectors = new ArrayList <> ();
        collectors.add(() -> {
            return "계정별 등록할 권한";
        });

        //collectors.add(new SimpleGrantedAuthority("Role"));

        return collectors;
    }

    //계정이 만료되지 않았는지 리턴 (true: 만료 안됨)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //계정이 잠겨있는지 않았는지 리턴. (true: 잠기지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //비밀번호가 만료되지 않았는지 리턴한다. (true: 만료 안됨)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //계정이 활성화(사용가능)인지 리턴 (true: 활성화)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

컨트롤러(Controller)

  MemberController.java  


컨트롤러에서는 로그인 페이지 매핑만 하고 로그인 실패, 예외 처리에 대한 값을 받아 model에 넣어준다. 실제 로그인 처리는 스프링 시큐리티를 통해 진행한다.

 

package com.board.study.web;

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

@Controller
public class MemberController {

    @GetMapping("/login")
    public String getLoginPage(Model model,
        @RequestParam(value = "error", required = false) String error,
        @RequestParam(value = "exception", required = false) String exception) {
        model.addAttribute("error", error);
        model.addAttribute("exception", exception);
        return "/member/login";
    }
}

 

서비스(Service)

  MemberService.java  


UserDetailsService 인터페이스를 implements 하여 Spring Security에서 유저의 정보를 가져온다.
loadUserByUsername 메서드를 오버라이드하고, 접속하려는 이메일(아이디)이 존재하는지 데이터베이스에서 조회 후 해당 정보를 리턴한다.

 

package com.board.study.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;
import com.board.study.entity.board.member.Member;
import com.board.study.entity.board.member.MemberRepository;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        Member member = memberRepository.findByEmail(email);

        if (member == null) throw new UsernameNotFoundException("Not Found account.");

        return member;
    }
}

 

리포지토리(Repository)

  MemberRepository.java  

 

로그인 성공 시 최종 로그인 일시를 업데이트할 쿼리와 이메일로 정보를 찾을 때 사용할 finbyEmail 메서드를 생성한다.

 

package com.board.study.entity.board.member;

import java.time.LocalDateTime;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

public interface MemberRepository extends JpaRepository < Member, Long > {

    static final String UPDATE_MEMBER_LAST_LOGIN = "UPDATE Member SET LAST_LOGIN_TIME = :lastLoginTime WHERE EMAIL = :email";

    @Transactional
    @Modifying
    @Query(value = UPDATE_MEMBER_LAST_LOGIN, nativeQuery = true)
    public int updateMemberLastLogin(@Param("email") String email, @Param("lastLoginTime") LocalDateTime lastLoginTime);
    public Member findByEmail(String emfail);
}

 

로그인 성공 핸들러

  AuthSucessHandler.java  


SimpleUrlAuthenticationSuccessHandler 상속받아 로그인 성공 시 처리할 로직을 작성한다. 마지막 로그인 일시를 업데이트하고 리턴할 URL을 작성한다.

 

package com.board.study.member.handler;

import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import com.board.study.entity.board.member.MemberRepository;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Component
public class AuthSucessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final MemberRepository memberRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        memberRepository.updateMemberLastLogin(authentication.getName(), LocalDateTime.now());
        setDefaultTargetUrl("/board/list");

        super.onAuthenticationSuccess(request, response, authentication);
    }
}

 

로그인 실패 핸들러

  AuthFailureHandler.java  


SimpleUrlAuthenticationFailureHandler 상속받아 로그인 실패 시 처리할 로직을 작성한다. Exception에 대한 사용자 메시지를 처리 후 리턴 URL를 설정한다. 여기서 파라미터로 설정한 Exception 메시지가 로그인 페이지로 전달된다.

*여기서 로그인 실패 횟수에 대한 계정 잠금 처리 등을 추가로 작성할 수도 있다.

Exception 설명
UsernameNotFoundException 계정 없음
BadCredentialsException 비밀번호 불일치
AccountExpiredException 계정만료
CredentialExpiredException 비밀번호 만료
DisabledException 계정 비활성화
LockedException 계정잠김

 

package com.board.study.member.handler;

import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {

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

        String msg = "Invalid Email or Password";

        // exception 관련 메세지 처리
        if (exception instanceof DisabledException) {
            msg = "DisabledException account";
        } else if (exception instanceof CredentialsExpiredException) {
            msg = "CredentialsExpiredException account";
        } else if (exception instanceof BadCredentialsException) {
            msg = "BadCredentialsException account";
        }

        setDefaultFailureUrl("/login?error=true&exception=" + msg);

        super.onAuthenticationFailure(request, response, exception);
    }
}

 

스프링 시큐리티 적용 WebSecurity

  SecurityConfig.java  

 

URL 패턴, 로그인, 로그아웃, 성공 실패 Handler등을 처리한다.

rememberMe (로그인 정보를 쿠키에 저장해 두었다가 사용하는 기법) 설정 시 브라우저를 종료하고 다시 로그인이 필요한 페이지에 접근해도 인증을 요구하지 않는다.

브라우저 개발자도구 Application > Cookies > rememberMe 쿠키가 HttpOnly(XSS 방지)로 생성되어 있음을 확인할 수 있다.

 

Cookie remember-me

 

package com.board.study.config;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.board.study.member.handler.AuthFailureHandler;
import com.board.study.member.handler.AuthSucessHandler;
import com.board.study.service.MemberService;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@EnableWebSecurity // 시큐리티 필터 등록
@EnableGlobalMethodSecurity(prePostEnabled = true) // 특정 페이지에 특정 권한이 있는 유저만 접근을 허용할 경우 권한 및 인증을 미리 체크하겠다는 설정을 활성화한다.
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final MemberService memberService;
    private final AuthSucessHandler authSucessHandler;
    private final AuthFailureHandler authFailureHandler;

    // BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체 (BCrypt라는 해시 함수를 이용하여 패스워드를 암호화 한다.)
    // 회원 비밀번호 등록시 해당 메서드를 이용하여 암호화해야 로그인 처리시 동일한 해시로 비교한다.
    @Bean
    public BCryptPasswordEncoder encryptPassword() {
        return new BCryptPasswordEncoder();
    }

    // 시큐리티가 로그인 과정에서 password를 가로챌때 해당 해쉬로 암호화해서 비교한다.
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(memberService).passwordEncoder(encryptPassword());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /*
         csrf 토큰 활성화시 사용
         쿠키를 생성할 때 HttpOnly 태그를 사용하면 클라이언트 스크립트가 보호된 쿠키에 액세스하는 위험을 줄일 수 있으므로 쿠키의 보안을 강화할 수 있다.
        */
        //http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())

        http.csrf().disable() // csrf 토큰을 비활성화
            .authorizeRequests() // 요청 URL에 따라 접근 권한을 설정
            .antMatchers("/", "/login/**", "/js/**", "/css/**", "/image/**").permitAll() // 해당 경로들은 접근을 허용
            .anyRequest() // 다른 모든 요청은
            .authenticated() // 인증된 유저만 접근을 허용
            .and()
            .formLogin() // 로그인 폼은
            .loginPage("/login") // 해당 주소로 로그인 페이지를 호출한다.
            .loginProcessingUrl("/login/action") // 해당 URL로 요청이 오면 스프링 시큐리티가 가로채서 로그인처리를 한다. -> loadUserByName
            .successHandler(authSucessHandler) // 성공시 요청을 처리할 핸들러
            .failureHandler(authFailureHandler) // 실패시 요청을 처리할 핸들러
            .and()
            .logout()
            .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) // 로그아웃 URL
            .logoutSuccessUrl("/login") // 성공시 리턴 URL
            .invalidateHttpSession(true) // 인증정보를 지우하고 세션을 무효화
            .deleteCookies("JSESSIONID","remember-me") // JSESSIONID, remember-me 쿠키 삭제
            .permitAll()
            .and()
            .sessionManagement()
            .maximumSessions(1) // 세션 최대 허용 수 1, -1인 경우 무제한 세션 허용
            .maxSessionsPreventsLogin(false) // true면 중복 로그인을 막고, false면 이전 로그인의 세션을 해제
            .expiredUrl("/login?error=true&exception=Have been attempted to login from a new place. or session expired") // 세션이 만료된 경우 이동 할 페이지를 지정
            .and()
            .and().rememberMe() // 로그인 유지
            .alwaysRemember(false) // 항상 기억할 것인지 여부
            .tokenValiditySeconds(43200) // in seconds, 12시간 유지
            .rememberMeParameter("remember-me");
    }
}

 

테스트

로그인 페이지

 

로그인 실패

중복 로그인

SecurityConfig에 maxSessionsPreventsLogin에 설정한 대로 이전에 로그인 사용자를 로그아웃 한다.

 

 

+ 사용자 인증 객체 가져오기

Member activeUser = (Member) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

 

테스트로 계정을 생성할 때 비밀번호 암호화 시 사용

new BCryptPasswordEncoder().encode("패스워드");

 

GitHub

 

프로젝트 Import 방법

압축을 풀고, 이클립스 import  →  General  →  Existing Projects into Workspace

board.zip
0.25MB

 

반응형