이번 글에서는 Spring Security를 이용하여 회원가입 및 로그인을 구현해보도록 하겠습니다.

전체 코드는 깃헙을 참고하시길 바랍니다.

  • 개발환경
    • IntelliJ 2019.02
    • Java 11
    • SpringBoot 2.1.9
    • Gradle 5.6
  • 라이브러리 일부
    • org.springframework.boot:spring-boot-starter-web:2.1.9.RELEASE 
    • org.projectlombok:lombok:1.18.10
    • org.springframework.boot:spring-boot-starter-data-jpa:2.1.9.RELEASE 
    • org.springframework.boot:spring-boot-starter-thymeleaf:2.1.9.RELEASE
    • org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.4.RELEASE
    • org.springframework.boot:spring-boot-starter-security:2.1.9.RELEASE 

[ 프로젝트 구조 ]

 


 

1. 의존성 추가

Spring Security를 사용하려면, 의존성을 추가해야합니다.

뿐만 아니라, Thymeleaf에서 Spring Security 통합 모듈을 사용하기 위한 의존성도 추가해줘야 합니다. ( 참고 )

 

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

 

 

 

 

2. Spring Security 설정

먼저 이 글의 핵심인 Spring Security를 설정해보도록 하겠습니다.

 

Spring Security는 FilterChainProxy라는 이름으로 내부에 여러 Filter들이 동작하고 있습니다. ( 참고 )

 

그래서 간단한 구현단계에서는 별도의 로직을 작성하지 않아도 설정만으로 로그인/로그아웃 등의 처리가 가능합니다.

설정은 WebSecurityConfigurerAdapter라는 클래스를 상속받은 클래스에서 메서드를 오버라이딩하여 조정할 수 있는데요, 그 클래스를 구현해보겠습니다.

 

 

src/main/java/com/victolee/signuplogin/config/SecurityConfig.java

package com.victolee.signuplogin.config;

import com.victolee.signuplogin.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
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 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private MemberService memberService;

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

@Override
public void configure(WebSecurity web) throws Exception
{
// static 디렉터리의 하위 파일 목록은 인증 무시 ( = 항상통과 )
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 페이지 권한 설정
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/myinfo").hasRole("MEMBER")
.antMatchers("/**").permitAll()
.and() // 로그인 설정
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/user/login/result")
.permitAll()
.and() // 로그아웃 설정
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/user/logout/result")
.invalidateHttpSession(true)
.and()
// 403 예외처리 핸들링
.exceptionHandling().accessDeniedPage("/user/denied");
}

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
}
}

  • @EnableWebSecurity
    • @Configuration 클래스에 @EnableWebSecurity 어노테이션을 추가하여 Spring Security 설정할 클래스라고 정의합니다.
    • 설정은 WebSebSecurityConfigurerAdapter 클래스를 상속받아 메서드를 구현하는 것이 일반적인 방법입니다. 
  • WebSecurityConfigurerAdapter 클래스
  • passwordEncoder()
    • BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체입니다.
    • Service에서 비밀번호를 암호화할 수 있도록 Bean으로 등록합니다.

 

다음으로 configure() 메서드를 오버라이딩하여, Security 설정을 잡아줍니다.

  • configure(WebSecurity web)
    • WebSecurity는 FilterChainProxy를 생성하는 필터입니다.
    • web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
      • 해당 경로의 파일들은 Spring Security가 무시할 수 있도록 설정합니다.
      • 즉, 이 파일들은 무조건 통과하며, 파일 기준은 resources/static 디렉터리입니다. ( css, js 등의 디렉터리를 추가하진 않았습니다. )
  • configure(HttpSecurity http)
    • HttpSecurity를 통해 HTTP 요청에 대한 웹 기반 보안을 구성할 수 있습니다.
    • authorizeRequests()
      • HttpServletRequest에 따라 접근(access)을 제한합니다.
      • antMatchers() 메서드로 특정 경로를 지정하며, permitAll(), hasRole() 메서드로 역할(Role)에 따른 접근 설정을 잡아줍니다. 여기서 롤은 권한을 의미합니다. 즉 어떤 페이지는 관리지만 접근해야 하고, 어떤 페이지는 회원만 접근해야할 때 그 권한을 부여하기 위해 역할을 설정하는 것입니다. 예를 들어,
        • .antMatchers("/admin/**").hasRole("ADMIN")
          • /admin 으로 시작하는 경로는 ADMIN 롤을 가진 사용자만 접근 가능합니다.
        • .antMatchers("/user/myinfo").hasRole("MEMBER")
          • /user/myinfo 경로는 MEMBER 롤을 가진 사용자만 접근 가능합니다.
        • .antMatchers("/**").permitAll()
          • 모든 경로에 대해서는 권한없이 접근 가능합니다.
        • .anyRequest().authenticated()
          • 모든 요청에 대해, 인증된 사용자만 접근하도록 설정할 수도 있습니다. ( 예제에는 적용 안함 )
    • formlogin()
      • form 기반으로 인증을 하도록 합니다. 로그인 정보는 기본적으로 HttpSession을 이용합니다.
      • /login 경로로 접근하면, Spring Security에서 제공하는 로그인 form을 사용할 수 있습니다.
      • .loginPage("/user/login")
        • 기본 제공되는 form 말고, 커스텀 로그인 폼을 사용하고 싶으면 loginPage() 메서드를 사용합니다.
        • 이 때 커스텀 로그인 form의 action 경로와 loginPage()의 파라미터 경로가 일치해야 인증을 처리할 수 있습니다. ( login.html에서 확인 )
      • .defaultSuccessUrl("/user/login/result")
        • 로그인이 성공했을 때 이동되는 페이지이며, 마찬가지로 컨트롤러에서 URL 매핑이 되어 있어야 합니다.
      • .usernameParameter("파라미터명")
        • 로그인 form에서 아이디는 name=username인 input을 기본으로 인식하는데, usernameParameter() 메서드를 통해 파라미터명을 변경할 수 있습니다. ( 예제에는 적용 안함 )
    • logout()
      • 로그아웃을 지원하는 메서드이며, WebSecurityConfigurerAdapter를 사용할 때 자동으로 적용됩니다.
      • 기본적으로 "/logout"에 접근하면 HTTP 세션을 제거합니다.
      • .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
        • 로그아웃의 기본 URL(/logout) 이 아닌 다른 URL로 재정의합니다.
      • .invalidateHttpSession(true)
        • HTTP 세션을 초기화하는 작업입니다.
      • deleteCookies("KEY명")
        • 로그아웃 시, 특정 쿠기를 제거하고 싶을 때 사용하는 메서드입니다. ( 예제에는 적용안함 )
    • .exceptionHandling().accessDeniedPage("/user/denied");
      • 예외가 발생했을 때 exceptionHandling() 메서드로 핸들링할 수 있습니다.
      • 예제에서는 접근권한이 없을 때, 로그인 페이지로 이동하도록 명시해줬습니다
  • configure(AuthenticationManagerBuilder auth)
    • Spring Security에서 모든 인증은 AuthenticationManager를 통해 이루어지며 AuthenticationManager를 생성하기 위해서는 AuthenticationManagerBuilder를 사용합니다.
      • 로그인 처리 즉, 인증을 위해서는 UserDetailService를 통해서 필요한 정보들을 가져오는데, 예제에서는 서비스 클래스(memberService)에서 이를 처리합니다.
      • 서비스 클래스에서는 UserDetailsService 인터페이스를 implements하여, loadUserByUsername() 메서드를 구현하면 됩니다.
    • 비밀번호 암호화를 위해, passwordEncoder를 사용하고 있습니다.

 

코드는 간단한데, 풀어쓰니 조금 복잡해진 것 같습니다.

특히 HttpSecurity 부분이 조금 복잡한데, 위 링크에서 API들을 참고하시면 좋을 것 같습니다.

 

 

 

 

3. 컨트롤러 / 서비스 / 도메인 등 구현

다음으로 각 Layer들을 구현해보도록 하겠습니다.

Service를 유념해서 봐주시길 바라며, 로그인, 로그아웃에 대한 로직이 없는 것도 살펴보시길 바랍니다.

 

1) src/main/java/com/victolee/signuplogin/controller/MemberController.java

package com.victolee.signuplogin.controller;

import com.victolee.signuplogin.dto.MemberDto;
import com.victolee.signuplogin.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
@AllArgsConstructor
public class MemberController {
private MemberService memberService;

// 메인 페이지
@GetMapping("/")
public String index() {
return "/index";
}

// 회원가입 페이지
@GetMapping("/user/signup")
public String dispSignup() {
return "/signup";
}

// 회원가입 처리
@PostMapping("/user/signup")
public String execSignup(MemberDto memberDto) {
memberService.joinUser(memberDto);

return "redirect:/user/login";
}

// 로그인 페이지
@GetMapping("/user/login")
public String dispLogin() {
return "/login";
}

// 로그인 결과 페이지
@GetMapping("/user/login/result")
public String dispLoginResult() {
return "/loginSuccess";
}

// 로그아웃 결과 페이지
@GetMapping("/user/logout/result")
public String dispLogout() {
return "/logout";
}

// 접근 거부 페이지
@GetMapping("/user/denied")
public String dispDenied() {
return "/denied";
}

// 내 정보 페이지
@GetMapping("/user/info")
public String dispMyInfo() {
return "/myinfo";
}

// 어드민 페이지
@GetMapping("/admin")
public String dispAdmin() {
return "/admin";
}
}

컨트롤러에서 특별한 것은 없습니다.

 

 

2) src/main/java/com/victolee/signuplogin/service/MemberService.java

package com.victolee.signuplogin.service;

import com.victolee.signuplogin.domain.Role;
import com.victolee.signuplogin.domain.entity.MemberEntity;
import com.victolee.signuplogin.domain.repository.MemberRepository;
import com.victolee.signuplogin.dto.MemberDto;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Service
@AllArgsConstructor
public class MemberService implements UserDetailsService {
private MemberRepository memberRepository;

@Transactional
public Long joinUser(MemberDto memberDto) {
// 비밀번호 암호화
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
memberDto.setPassword(passwordEncoder.encode(memberDto.getPassword()));

return memberRepository.save(memberDto.toEntity()).getId();
}

@Override
public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException {
Optional<MemberEntity> userEntityWrapper = memberRepository.findByEmail(userEmail);
MemberEntity userEntity = userEntityWrapper.get();

List<GrantedAuthority> authorities = new ArrayList<>();

if (("admin@example.com").equals(userEmail)) {
authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(Role.MEMBER.getValue()));
}

return new User(userEntity.getEmail(), userEntity.getPassword(), authorities);
}
}

  • joinUser()
    • 회원가입을 처리하는 메서드이며, 비밀번호를 암호화하여 저장합니다.
  • loadUserByUsername()
    • 상세 정보를 조회하는 메서드이며, 사용자의 계정정보와 권한을 갖는 UserDetails 인터페이스를 반환해야 합니다.
    • 매개변수는 로그인 시 입력한 아이디인데, 엔티티의 PK를 뜻하는게 아니고 유저를 식별할 수 있는 어떤 값을 의미합니다. Spring Security에서는 username라는 이름으로 사용합니다.
      • 예제에서는 아이디가 이메일이며, 로그인을 하는 form에서 name="username"으로 요청해야 합니다.
    • authorities.add(new SimpleGrantedAuthority());
      • 롤을 부여하는 코드입니다. 롤 부여 방식에는 여러가지가 있겠지만, 회원가입할 때 Role을 정할 수 있도록 Role Entity를 만들어서 매핑해주는 것이 좋은 방법인것 같습니다. ( 참고 )
      • 예제에서는 복잡성을 줄이기 위해, 아이디가 "admin@example.com"일 경우에 ADMIN 롤을 부여했습니다.
    • new User()
      • return은 SpringSecurity에서 제공하는 UserDetails를 구현한 User를 반환합니다. ( org.springframework.security.core.userdetails.User )
      • 생성자의 각 매개변수는 순서대로 아이디, 비밀번호, 권한리스트입니다.

 

 

3) src/main/java/com/victolee/signuplogin/domain/Role.java

package com.victolee.signuplogin.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum Role {
ADMIN("ROLE_ADMIN"),
MEMBER("ROLE_MEMBER");

private String value;
}

  • Service에서 사용하는 Enum객체입니다.

 

 

4) src/main/java/com/victolee/signuplogin/domain/repository/MemberRepository.java

package com.victolee.signuplogin.domain.repository;

import com.victolee.signuplogin.domain.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
Optional<MemberEntity> findByEmail(String userEmail);
}

  • Email을 Where 조건절로 하여, 데이터를 가져올 수 있도록 findByEmail() 메서드를 정의했습니다.

 

 

5) src/main/java/com/victolee/signuplogin/domain/entity/MemberEntity.java

package com.victolee.signuplogin.domain.entity;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "member")
public class MemberEntity {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;

@Column(length = 20, nullable = false)
private String email;

@Column(length = 100, nullable = false)
private String password;

@Builder
public MemberEntity(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
}

6) src/main/java/com/victolee/signuplogin/dto/MemberDto.java

package com.victolee.signuplogin.dto;

import com.victolee.signuplogin.domain.entity.MemberEntity;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@NoArgsConstructor
public class MemberDto {
private Long id;
private String email;
private String password;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;

public MemberEntity toEntity(){
return MemberEntity.builder()
.id(id)
.email(email)
.password(password)
.build();
}

@Builder
public MemberDto(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
}

Entity와 Dto에서도 특별한 것은 없습니다.

 

 

 

 

4. 퍼블리싱

마지막으로 HTML을 구현해보도록 하겠습니다.

테스트를 위한 페이지들이 많으며, 핵심 파일은 index.html, login.html 입니다.

 

1) src/main/resources/templates/admin.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>어드민</title>
</head>
<body>
<h1>어드민 페이지입니다.</h1>
<hr>
</body>
</html>

 

 

2) src/main/resources/templates/denied.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>접근 거부</title>
</head>
<body>
<h1>접근 불가 페이지입니다.</h1>
<hr>
</body>
</html>

 

 

3) src/main/resources/templates/index.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>메인</title>
</head>
<body>
<h1>메인 페이지</h1>
<hr>
<a sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
<a sec:authorize="isAnonymous()" th:href="@{/user/signup}">회원가입</a>
<a sec:authorize="hasRole('ROLE_MEMBER')" th:href="@{/user/info}">내정보</a>
<a sec:authorize="hasRole('ROLE_ADMIN')" th:href="@{/admin}">어드민</a>
</body>
</html>

  • sec:authorize를 사용하여, 사용자의 Role에 따라 보이는 메뉴를 다르게 합니다.
    • isAnonymous()
      • 익명의 사용자일 경우, 로그인, 회원가입 버튼을 노출합니다.
    • isAuthenticated()
      • 인증된 사용자일 경우, 로그아웃 버튼을 노출줍니다.
    • hasRole()
      • 특정 롤을 가진 사용자에 대해, 메뉴를 노출합니다.

 

 

4) src/main/resources/templates/login.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인</h1>
<hr>

<form action="/user/login" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />

<input type="text" name="username" placeholder="이메일 입력해주세요">
<input type="password" name="password" placeholder="비밀번호">
<button type="submit">로그인</button>
</form>
</body>
</html>

  • <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
    • form에 히든 타입으로 csrf 토큰 값을 넘겨줍니다.
    • Spring Security가 적용되면 POST 방식으로 보내는 모든 데이터는 csrf 토큰 값이 필요합니다. ( 뒤에서 살펴 볼 join.html에서는 편리한 방법을 사용하고 있습니다. )
      • 토큰 값이 없는 상태에서 form 전송을 할 경우, 컨트롤러에 POST 메서드를 매핑할 수 없다는 에러가 발생합니다.
      • error : HttpRequestMethodNotSupportedException: Request method 'POST'
  • <input type="text" name="username" placeholder="이메일 입력해주세요">
    • 로그인 시 아이디의 name 애트리뷰트 값은 username이어야 합니다.

 

 

5) src/main/resources/templates/loginSuccess.html

<!DOCTYPE html>
<html lang="en" xmlns:sec="" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그인 성공</title>
</head>
<body>
<h1>로그인 성공!!</h1>
<hr>
<p>
<span sec:authentication="name"></span>님 환영합니다~
</p>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>

  • sec:authentication="name"
    • useranme 값을 가져옵니다. ( 참고 )

 

 

6) src/main/resources/templates/logout.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그아웃</title>
</head>
<body>
<h1>로그아웃 처리되었습니다.</h1>
<hr>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>

 

 

7) src/main/resources/templates/myinfo.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>내정보</title>
</head>
<body>
<h1>내정보 확인 페이지입니다.</h1>
<hr>
</body>
</html>

 

 

8) src/main/resources/templates/signup.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원 가입</h1>
<hr>

<form th:action="@{/user/signup}" method="post">
<input type="text" name="email" placeholder="이메일 입력해주세요">
<input type="password" name="password" placeholder="비밀번호">
<button type="submit">가입하기</button>
</form>
</body>
</html>

  • 로그인 페이지와 달리 input hidden 타입으로 csrf 토큰 값을 넘겨주지 않고 있는데요, th:action을 사용하면 Thymeleaf가 csrf 토큰 값을 자동으로 추가해주므로 편리합니다.

 

 

 

 

5. 테스트

이제 애플리케이션을 실행하고, 회원가입/로그인/로그아웃 및 권한에 따른 메뉴가 잘 보이는지 확인하시길 바랍니다.

 

 

  • 1~4
    • 어드민 계정( admin@example.com )으로 회원가입 및 로그인을 진행합니다.
  • 5~6
    • 메인 페이지에서 "어드민" 버튼이 노출되는 것을 확인하고, 접근을 해봅니다.
  • 7
    • 브라우저 URL에 "/user/myinfo"를 입력하여 멤버만 접근할 수 있는 페이지에 접근 불가능한지 확인합니다.
  • 8
    • 로그아웃을 합니다.

 

다음으로 어드민 이메일이 아닌 아이디로 가입하여 같은 테스트를 해봅니다. ( MEMBER 롤을 가지므로, admin 페이지에는 접근 불가 )

 

 

 

 

이상으로 Spring Security를 이용하여 회원가입/로그인/로그아웃을 진행해보았습니다.

Security는 설정만 해주면 Filter, Interceptor에서 인증을 해주기 때문에 한 번 작성하고 나면, 딱히 건드리지 않는 부분이긴합니다.

 

본문에서는 다루지 않았지만, 커스텀 로그인 성공, 로그인 실패, 로그아웃 처리 등을 할 수 있는 handler들이 있습니다.

이 내용도 다루고 싶었지만, 글이 길어지는 관계로 링크로 대체하겠습니다.

AuthenticationSuccessHandler / AuthenticationFailureHandler ( 참고 )

SimpleUrlLogoutSuccessHandler ( 참고 )

 

 

[ 참고자료 ]

https://spring.io/guides/topicals/spring-security-architecture#_web_security

https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/builders/HttpSecurity.html#headers--

https://gs.saro.me/dev?tn=481

https://galid1.tistory.com/576

https://velog.io/@hellozin/Spring-Security-Form-Login-%EA%B0%84%EB%8B%A8-%EC%82%AC%EC%9A%A9-%EC%84%A4%EB%AA%85%EC%84%9C-f2jzojj8bj

https://xmfpes.github.io/spring/spring-security/

http://progtrend.blogspot.com/2018/07/spring-boot-security.html

 

 

 

 

출처 : victorydntmd.tistory.com/328

 

 

 

 

 

 

 

 

 

 

+ Recent posts