본문 바로가기
웹개발/스프링시큐리티

4. 스프링 시큐리티 - 회원가입

by 어컴띵 2021. 4. 19.

이번에는 회원가입후 로그인 처리를 한번 해보자

 

프로젝트는 이미 셋팅했다고 가정하고 진행을 한다.

 

프로젝트 설정

build.gradle

plugins {
    id 'org.springframework.boot' version '2.4.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.samlasoft'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    compileOnly 'org.projectlombok:lombok'
    compileOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

test {
    useJUnitPlatform()
}

 

디렉토리 구조

환경 설정

스프링 시큐리티 설정

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests( authorize -> authorize
                        .antMatchers("/css/**","/js/**","/index").permitAll()
                        .antMatchers("/user/**").hasRole("USER")
                )
                .formLogin( formLogin -> formLogin
                        .loginPage("/login"));
    }

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

h2database 설정

@Configuration
@Profile("local")
public class H2ServerConfiguration {
    @Value("${spring.h2.port}")
    String port;

    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    public DataSource dataSource() throws SQLException {
        Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", port).start();
        return new HikariDataSource();
    }
}

application.yml

spring:
  h2:
    console:
      enabled: true
    port: 9083
  profiles:
    active: local
  datasource:
    hikari:
      jdbc-url: jdbc:h2:./data/testdb
      driver-class-name: org.h2.Driver
      username: sa
      password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    root: debug

 

엔티티 추가

User 엔티티

유저를 나타나는 클래스로 username, password, mail 정보와 계정의 잠금관련 정보가 있다. 그리고 권한리스트를 가지고 있으며 OneToMany이므로 일대다관계이다. primary key로는 id가 되어 있지만 mail이 유저정보를 불러오는 키이다. username 유저명이며, 로그인시에 스프링시큐리티에서 사용하는 username은 mail이다.

@Getter
@Setter
@Entity
@Table(name="user")
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue
    private Long id;
    private String username;
    private String password;
    @Column(name="mail", unique = true)
    private String mail;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialNonExpired;
    private boolean enabled;
    @OneToMany(mappedBy = "user")
    private List<Role> roleList = new ArrayList<>();

    public User(String mail) {
        this.mail = mail;
    }

    public static User from(String mail){
        return new User(mail);
    }
}

Role

유저의 권한을 나타내며 'ROLE_' 을 접두사로 붙인다. 유저는 여러개의 권한을 가질수 있는 구조로 되어 있다.

@Getter
@Setter
@Entity
public class Role  {
    @Id
    @GeneratedValue
    private Long id;
    private String role;
    @ManyToOne
    private User user;
}

CustomUserDetails

이전글에서는 user엔티티가 UserDetails의 구현체였지만 이번 버전에서는 별도의 클래스로 분리하였다. 그리고 Role엔티티도 마찬가지로 GrantedAuthority의 구현체현지만 여기서는 Role엔티티로 정의하고 CustomUserDtetails클래스에서 Role엔티티를 SimpleGrantedAuthority로 셋팅해서 권한을 반환하는 구조로 변경하였다.

public class CustomUserDetails implements UserDetails {

    private User user;

    public CustomUserDetails(User user){
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<Role> roleList = user.getRoleList();
        List<GrantedAuthority> authorities = new ArrayList<>();
        for(Role role : roleList){
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getRole());
            authorities.add(simpleGrantedAuthority);
        }
        return authorities;
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return user.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return user.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return user.isCredentialNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return user.isEnabled();
    }
}

Repository

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User,Long> {
}

RoleRepository

유저로 Role을 검색하는 메서드를 추가한다.

@Repository
public interface RoleRepository extends JpaRepository<Role,Long> {
    public List<Role> findByUser(User user);
}

Service

UserDetailsService의 구현체인 UserDetailsServiceImpl클래스를 작성한다. 이클래스는 스프링시큐리티에서 로그인시에 유저정보를 가져오는 역할을 한다.

UserDetailsServiceImpl

UserRespository에서 유저정보를 가져오면 Role은 실제데이타가 있는게 아니고 프록시 객체가 존재한다. 그래서 스피링시큐리티에서 권한을 체크할때 프록시 객체를 가져오게 되는데 lazyloading으로 데이타를 가져와야되는데 이미 프록시 객체는 영속성을 벗어났기 때문에 정보를 가져올수가 없다. 그래서 여기서는 권한을 직접가져와서 유저정보에 셋팅을 해준다.

UserRepository에서 username 으로 유저정보를 가져와야데는데 여기서 unsername는 priamrykey가 아니기 때문 findById 메소드를 이용하지 못하고, UserRepsotory는 JpaRepository를 상속받아서 findOne이라는 메소드를 이용해서 가져온다. findOne메소드는 Example를 인수로 받는데 Example.of메소드에 인수로 User,와 ExampleMather를 이용해서 User정보를 가져온다. ExampleMacher의 withIgnorePaths메소드를 사용해서 where조건에서 제거할 필드를 명시해주고 withIgnoreNullValues의 필드도 제거해서 username으로만 정보를 가져오게 한다.

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private JpaRepository<User,Long> userRepository;
    private RoleRepository roleRepository;

    public UserDetailsServiceImpl(JpaRepository userRepository, RoleRepository roleRepository){
        this.userRepository = userRepository;
        this.roleRepository = roleRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        ExampleMatcher matcher = ExampleMatcher.matching()
                .withIgnorePaths("credentialNonExpired","enabled","accountNonExpired","accountNonLocked")
                .withIgnoreNullValues();

        User user = userRepository.findOne(Example.of(User.from(username),matcher)).orElseThrow(() -> new UsernameNotFoundException(" username not found! "));
        user.setRoleList(roleRepository.findByUser(user));
        CustomUserDetails customUserDetails = new CustomUserDetails(user);
        return customUserDetails;
    }
}

Controller

UserController

유저 관련 컨트롤러이며 registeMember메서드가 회원가입 메서드이다. post로 넘어온 값은 dto인 SignVo에 저장되고 SignVo정보를 유저클래스에 셋팅하고, 유저에 권한정보를 셋팅후 저장한다. 저장할때는 유저정보와 권한정보를 같이저장한다. 메일주소가 중복일경우 error파라메타를 내보내서 가입페이지에 에러를 표시한다.

package com.samlasoft.springsecurity.register.user.web;

import com.samlasoft.springsecurity.register.user.domain.Role;
import com.samlasoft.springsecurity.register.user.domain.User;
import org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.net.http.HttpResponse;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

@Controller
public class UserController {

    private PasswordEncoder passwordEncoder;
    private JpaRepository<User, Long> userRepository;
    private JpaRepository<Role, Long> roleRepository;


    public UserController(JpaRepository userRepository,JpaRepository roleRepository, PasswordEncoder passwordEncoder){
        this.userRepository = userRepository;
        this.roleRepository = roleRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @RequestMapping("/user/profile")
    public String profile(){
        return "user/profile";
    }

    @RequestMapping("/login")
    public String login(){
        return "login/index";
    }

    @RequestMapping("/register")
    public String register(){
        return "login/register";
    }

    @PostMapping("/register")
    public String registerMember(@ModelAttribute SignVo signVo){
        User user = getUser(signVo);
        try{
            userRepository.save(user);
            roleRepository.save(user.getRoleList().get(0));
        }catch (DataIntegrityViolationException ex) {
             return "redirect:/register?error=duple";
        }
        return "login/complet";
    }

    private User getUser(SignVo signVo){
        List<Role> roleList = getRoleList();
        User user = User.builder()
                .username(signVo.getName())
                .mail(signVo.getEmail())
                .password(passwordEncoder.encode(signVo.getPassword()))
                .accountNonExpired(true)
                .accountNonLocked(true)
                .credentialNonExpired(true)
                .enabled(true)
                .roleList(roleList)
                .build();
        Role role = roleList.get(0);
        role.setUser(user);
        return user;
    }

    private List<Role> getRoleList(){
        List<Role> roleList = new ArrayList<>();
        Role role = new Role();
        role.setRole("ROLE_USER");
        roleList.add(role);
        return roleList;
    }
}

 

SignVo

post로 넘어오는 정보를 dto 클래스로 정의한다.

@Setter
@Getter
public class SignVo {
    private String name;
    private String email;
    private String password;
}

Html 작성

페이지 레이아웃

layout/nav.html

<nav th:fragment="nav" class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
  <a class="navbar-brand" href="#">AT</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
  </button>
<div  class="collapse navbar-collapse" id="navbarsExampleDefault">
  <ul class="navbar-nav mr-auto">
    <li class="nav-item active">
      <a class="nav-link" href="/">Home <span class="sr-only">(current)</span></a>
    </li>
  </ul>
  <ul class="navbar-nav ml-auto" sec:authorize="isAuthenticated()">
    <li>
      <a class="nav-link" href="/user/profile">프로필</a>
    </li>
    <li>
      <a class="nav-link" href="/logout">로그아웃</a>
    </li>
  </ul>
  <ul class="navbar-nav ml-auto" sec:authorize="!isAuthenticated()">
    <li>
      <a class="nav-link" href="/login">로그인</a>
    </li>
    <li>
      <a class="nav-link" href="/register">회원가입</a>
    </li>
  </ul>
</div>
</nav>

layout/head.html

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
<meta name="generator" content="Jekyll v4.1.1">
<title>Admin Template</title>
<link rel="canonical" href="https://getbootstrap.com/docs/4.5/examples/starter-template/">
<!-- Bootstrap core CSS -->
<link href="/css/bootstrap.min.css" rel="stylesheet">
<style>
  .bd-placeholder-img {
    font-size: 1.125rem;
    text-anchor: middle;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
  }
  @media (min-width: 768px) {
    .bd-placeholder-img-lg {
      font-size: 3.5rem;
    }
  }
</style>
<!-- Custom styles for this template -->
<link href="/css/starter-template.css" rel="stylesheet">

layout/header.html

<!doctype html>
<html lang="en">
<head>
  <th:block th:replace="layout/head"/>
</head>
<body>
<div th:replace="layout/nav::nav"></div>

layout/footer.html

<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script>window.jQuery || document.write('<script src="/js/vendor/jquery.slim.min.js"><\/script>')</script><script src="/js/bootstrap.bundle.min.js"></script>
</body>
</html>

인덱스페이지

index.html

<th:block th:replace="layout/header"/>
<main role="main" class="container">
  <div class="starter-template">
    <h1>Spring Security DEMO</h1>
    <p class="lead">스프링 시큐리티 데모 페이지 입니다.</p>
  </div>
</main><!-- /.container -->
<th:block th:replace="layout/footer"/>

가입페이지

login/register.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta name="description" content="">
  <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
  <meta name="generator" content="Jekyll v4.1.1">
  <title>Signin Template · Bootstrap</title>

  <link rel="canonical" href="https://getbootstrap.com/docs/4.5/examples/sign-in/">

  <!-- Bootstrap core CSS -->
  <link href="/css/bootstrap.min.css" rel="stylesheet">

  <style>
    .bd-placeholder-img {
      font-size: 1.125rem;
      text-anchor: middle;
      -webkit-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
      user-select: none;
    }

    @media (min-width: 768px) {
      .bd-placeholder-img-lg {
        font-size: 3.5rem;
      }
    }
  </style>
  <!-- Custom styles for this template -->
  <link href="/css/signin.css" rel="stylesheet">
</head>
<body class="text-center">
<form action="/register" class="form-signin" method="post">
  <a href="/"><img class="mb-4" src="/images/bootstrap-solid.svg" alt="" width="72" height="72"></a>
  <h1 class="h3 mb-3 font-weight-normal">회원가입</h1>
  <label for="name" class="sr-only">이름</label>
  <input type="text" name="name" id="name" class="form-control" placeholder="이름" required autofocus>
  <label for="email" class="sr-only">메일</label>
  <input type="email" name="email" id="email" class="form-control" placeholder="메일" required>
  <label for="inputPassword" class="sr-only">비밀번호</label>
  <input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required>
  <div th:if="${param.error}">
    <div th:if="${param.error.toString().equals('duple')}" class="alert alert-danger">
      메일주소가 중복됩니다.메일주소를 확인해 주세요.
    </div>
  </div>
  <button class="btn btn-lg btn-primary btn-block" type="submit">회원가입</button>
  <p class="mt-5 mb-3 text-muted">&copy; 2017-2020</p>
</form>
</body>
</html>

login/complet.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
    <meta name="generator" content="Jekyll v4.1.1">
    <title>Signin Template · Bootstrap</title>

    <link rel="canonical" href="https://getbootstrap.com/docs/4.5/examples/sign-in/">

    <!-- Bootstrap core CSS -->
    <link href="/css/bootstrap.min.css" rel="stylesheet">

    <style>
        .bd-placeholder-img {
            font-size: 1.125rem;
            text-anchor: middle;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        @media (min-width: 768px) {
            .bd-placeholder-img-lg {
                font-size: 3.5rem;
            }
        }
    </style>
    <!-- Custom styles for this template -->
    <link href="/css/signin.css" rel="stylesheet">
</head>
<body class="text-center">
<main role="main" class="container">
    <div class="starter-template">
        <h1>Spring Security DEMO</h1>
        <p class="lead">가입완료.</p>
        <p class="lead"><a href="/login">로그인</a></p>
    </div>
</main>
</body>
</html>

로그인페이지

login/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
    <meta name="generator" content="Jekyll v4.1.1">
    <title>Signin Template · Bootstrap</title>

    <link rel="canonical" href="https://getbootstrap.com/docs/4.5/examples/sign-in/">

    <!-- Bootstrap core CSS -->
<link href="/css/bootstrap.min.css" rel="stylesheet">

    <style>
      .bd-placeholder-img {
        font-size: 1.125rem;
        text-anchor: middle;
        -webkit-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
      }

      @media (min-width: 768px) {
        .bd-placeholder-img-lg {
          font-size: 3.5rem;
        }
      }
    </style>
    <!-- Custom styles for this template -->
    <link href="/css/signin.css" rel="stylesheet">
  </head>
  <body class="text-center">
    <form action="/login" class="form-signin" method="post">
  <a href="/"><img class="mb-4" src="/images/bootstrap-solid.svg" alt="" width="72" height="72"></a>
  <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
  <label for="username" class="sr-only">User name</label>
  <input type="text" name="username" id="username" class="form-control" placeholder="User name" required autofocus>
  <label for="inputPassword" class="sr-only">Password</label>
  <input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required>
  <div class="checkbox mb-3">
    <label>
      <input type="checkbox" value="remember-me"> Remember me
    </label>
  </div>
      <div th:if="${param.error}" class="alert alert-danger">
        메일 혹은 비밀번호를 다시 확인해 주세요.
      </div>
  <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
  <p class="mt-5 mb-3 text-muted">&copy; 2017-2020</p>
</form>
</body>
</html>

마치며

이번에는 회원가입을 구현하여 로그인 기능을 구현해보았다. User, Role엔티티를 만들고 스프링시큐리티에서 인증시 필요한 UserDetails, UserDetailsService의 구현체 CustomUserDetails, UserDetailsServiceImple을 만들고, 회원가입도 추가하여 가입하고 로그인하는 기능을 구현해보았다. 다음은 RememberMe기능을 한번 만들어보겠다.