Spring 숙련주차/1주차

7. 회원가입 구현

note994 2024. 8. 23. 19:04

Spring-auth 프로젝트 열기

 

Build.gradle에 아래의 코드를 추가한다.

// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'

데이터베이스에 연결을 해야 하기 때문에 JPA와 MySQL을 등록한것이다.

 

application.properties로 이동

spring.datasource.url=jdbc:mysql://localhost:3306/auth
spring.datasource.username=root
spring.datasource.password={비밀번호}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

{비밀번호}는 우리 데이터베이스의 비밀번호를 입력한다.

 

그리고 auth 데이터베이스를 create 해준다.

create database auth;

 

그리고 InteliJ와 auth 데이터베이스를 연동한다.

controller 패키지를 만들고 HomeController, UserController 클래스를 만든다.

HomeController 클래스
UserController 클래스

그리고 templates 폴더에 3가지 html 파일을 만든다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
	<link rel="preconnect" href="https://fonts.gstatic.com">

  <link rel="stylesheet" href="/css/style.css">
  <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
  <script src="/js/basic.js"></script>
  <title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
  <div id="login-true" style="display: none">
    <div id="header-title-login-user">
      <span th:text="${username}"></span> 님의
    </div>
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="login-text" href="javascript:logout()">
      로그아웃
    </a>
  </div>
  <div id="login-false" >
    <div id="header-title-select-shop">
      My Select Shop
    </div>
    <a id="sign-text" href="/api/user/signup">
      회원가입
    </a>
    <a id="login-text" href="/api/user/login-page">
      로그인
    </a>
  </div>
</div>
</body>
</html>

index.html 코드

 

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link rel="stylesheet" type="text/css" href="/css/style.css">
  <meta charset="UTF-8">
  <title>로그인 페이지</title>
</head>
<body>
<div id="login-form">
  <div id="login-title">Log into Select Shop</div>
  <br>
  <br>
  <button id="login-id-btn" onclick="location.href='/api/user/signup'">
    회원 가입하기
  </button>
  <form action="/api/user/login" method="post">
    <div class="login-id-label">아이디</div>
    <input type="text" name="username" class="login-input-box">

    <div class="login-id-label">비밀번호</div>
    <input type="password" name="password" class="login-input-box">

    <button id="login-id-submit">로그인</button>
  </form>
  <div id="login-failed" style="display: none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>
</body>
<script>
  const href = location.href;
  const queryString = href.substring(href.indexOf("?")+1)
  if (queryString === 'error') {
    const errorDiv = document.getElementById('login-failed');
    errorDiv.style.display = 'block';
  }
</script>
</html>

login.html 코드

 

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link rel="stylesheet" type="text/css" href="/css/style.css">
  <meta charset="UTF-8">
  <title>회원가입 페이지</title>
  <script>
    function onclickAdmin() {
      // Get the checkbox
      var checkBox = document.getElementById("admin-check");
      // Get the output text
      var box = document.getElementById("admin-token");

      // If the checkbox is checked, display the output text
      if (checkBox.checked == true){
        box.style.display = "block";
      } else {
        box.style.display = "none";
      }
    }
  </script>
</head>
<body>
<div id="login-form">
  <div id="login-title">Sign up Select Shop</div>

  <form action="/api/user/signup" method="post">
    <div class="login-id-label">Username</div>
    <input type="text" name="username" placeholder="Username" class="login-input-box">

    <div class="login-id-label">Password</div>
    <input type="password" name="password" class="login-input-box">

    <div class="login-id-label">E-mail</div>
    <input type="text" name="email" placeholder="E-mail" class="login-input-box">

    <div>
      <input id="admin-check" type="checkbox" name="admin" onclick="onclickAdmin()" style="margin-top: 40px;">관리자
      <input id="admin-token" type="password" name="adminToken" placeholder="관리자 암호" class="login-input-box" style="display:none">
    </div>
    <button id="login-id-submit">회원 가입</button>
  </form>
</div>
</body>
</html>

signup.html 코드

 

 

그리고 static 폴더에 css, js 폴더를 만든다.

 

그런데 처음 만들 때 static.css.js 이런식으로 만들어 질텐데 그럴때는

Move Directory에 들어가서 static까지만 남기고 지우면 잘 된다

각각 파일을 만든다

* {
    font-family: 'Georgia', serif;
}

body {
    margin: 0px;
}

.header {
    height: 255px;
    box-sizing: border-box;
    background-color: #15aabf;
    color: white;
    text-align: center;
    padding-top: 80px;
    /*padding: 50px;*/
    font-size: 45px;
    font-weight: bold;
}

#header-title-login-user {
    font-size: 36px;
    letter-spacing: -1.08px;
}

#header-title-select-shop {
    margin-top: 20px;
    font-size: 45px;
    letter-spacing: 1.1px;
}

#login-form {
    width: 538px;
    height: 710px;
    margin: 70px auto 141px auto;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    align-items: center;
    /*gap: 96px;*/
    padding: 56px 0 0;
    border-radius: 10px;
    box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.15);
    background-color: #ffffff;
}

#login-title {
    width: 303px;
    height: 32px;
    /*margin: 56px auto auto auto;*/
    flex-grow: 0;
    font-family: SpoqaHanSansNeo;
    font-size: 32px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.96px;
    text-align: left;
    color: #212529;
}

#login-kakao-btn {
    border-width: 0;
    margin: 96px 0 8px;
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    /*margin: 0 0 8px;*/
    padding: 11px 12px;
    border-radius: 5px;
    background-color: #ffd43b;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    color: #414141;
}

#login-id-btn {
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    /*margin: 8px 0 0;*/
    padding: 11px 12px;
    border-radius: 5px;
    border: solid 1px #212529;
    background-color: #ffffff;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    color: #414141;
}

.login-input-box {
    border-width: 0;

    width: 370px !important;
    height: 52px;
    margin: 14px 0 0;
    border-radius: 5px;
    background-color: #e9ecef;
}

.login-id-label {
    /*width: 44.1px;*/
    /*height: 16px;*/
    width: 382px;
    padding-left: 11px;
    margin-top: 40px;
    /*margin: 0 337.9px 14px 11px;*/
    font-family: NotoSansCJKKR;
    font-size: 16px;
    font-weight: normal;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.8px;
    text-align: left;
    color: #212529;
}

#login-id-submit {
    border-width: 0;
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    margin: 40px 0 0;
    padding: 11px 12px;
    border-radius: 5px;
    background-color: #15aabf;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: normal;
    text-align: center;
    color: #ffffff;
}

#sign-text {
    position:absolute;
    top:48px;
    right:110px;
    font-size: 18px;
    font-family: SpoqaHanSansNeo;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: 0.36px;
    text-align: center;
    color: #ffffff;
}

#login-text {
    position:absolute;
    top:48px;
    right:50px;
    font-size: 18px;
    font-family: SpoqaHanSansNeo;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: 0.36px;
    text-align: center;
    color: #ffffff;
}

.alert-danger {
    color: #721c24;
    background-color: #f8d7da;
    border-color: #f5c6cb;
}

.alert {
    width: 300px;
    margin-top: 22px;
    padding: 1.75rem 1.25rem;
    border: 1px solid transparent;
    border-radius: .25rem;
}

style.css 코드

 

let host = 'http://' + window.location.host;

$(document).ready(function () {
    const auth = getToken();
    if(auth === '') {
        window.location.href = host + "/api/user/login-page";
    } else {
        $('#login-true').show();
        $('#login-false').hide();
    }
})

function logout() {
    // 토큰 삭제
    Cookies.remove('Authorization', { path: '/' });
    window.location.href = host + "/api/user/login-page";
}

function getToken() {
	  let auth = Cookies.get('Authorization');
	
		if(auth === undefined) {
	        return '';
	   }
	
		return auth;
}

basic.js 코드

 

entity 패키지에 User 클래스 추가

package com.sparta.springauth.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;
}

@Table 애노테이션을 사용하면 클래스의 이름인 User 테이블이 아니라 설정한 users 테이블이 만들어진다.

 

@Enumerated 애노테이션은 데이터베이스 컬럼에 저장할 때 사용하는 옵션

Enum타입을 데이터베이스 컬럼에 저장할 때 사용하는 애노테이션이다.

EnumType.STRING 이것은 Enum의 이름 그대로를 데이터베이스에 저장한다.

Enum의 이름이란 enum에서 USER과 ADMIN을 뜻한다.


패스워드와 암호화 이해

회원 등록 시 '비밀번호'는 사용자가 입력한 문자 그대로 DB에 등록하면 안된다.

'정보통신망법, 개인정보보호법'에 의해 비밀번호 암호화(Encryption)가 의무이다.

예를 들어보자

앨리스가 사이트에 회원가입을 하며 아이디, 패스워드를 입력했다.

아이디 : alice

패스워드 : nobodynobody

패스워드를 아래와 같이 DB에 평문 그대로 저장해 두었다고 해보자

만약 해커에 의해 회원정보가 갈취당한다면 앨리스의 패스워드는 모두가 알게된다.

꼭 해커 뿐만 아니라 DB 조회가 가능한 내부 관계자들도 앨리스의 패스워드를 보자마자 영원히 기억해버릴지도 모른다.

그래서 아래와 같이 암호화 후 패스워드 저장이 필요하다.

평문 -> (암호화 알고리즘) -> 암호문

"nobodynobody" ->"$2a$10$.."

만약 해커가 DB에 있는 앨리스의 패스워드 정보를 갈취하더라도 실제 암호를 알 수 없다. 그래서 복호화가 불가능한 '단방향' 암호 알고리즘 사용이 필요하다.


회원가입 API 구현

UserController 클래스로 이동한다.

 

위에 같은 URL을 가진 메서드가 있지만 그것은 GetMapping이고 이건 PostMapping이라서 다르기 때문에 괜찮다.

 

SignupRequestDto 클래스가 없어서 오류가 뜨는데 dto 패키지에 해당 클래스를 만들어준다.

package com.sparta.springauth.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SignupRequestDto {
    private String username;
    private String password;
    private String email;
    private boolean admin = false;
    private String adminToken = "";
}

SignupRequestDto 클래스 코드

 

service 패키지 만들고 UserService 클래스 만든다

package com.sparta.springauth.service;

import com.sparta.springauth.dto.SignupRequestDto;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    // ADMIN_TOKEN
    private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

    public void signup(SignupRequestDto requestDto) {
        String username = requestDto.getUsername();
        String password = passwordEncoder.encode(requestDto.getPassword());

        // 회원 중복 확인
        Optional<User> checkUsername = userRepository.findByUsername(username);
        if (checkUsername.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
        }

        // email 중복확인
        String email = requestDto.getEmail();
        Optional<User> checkEmail = userRepository.findByEmail(email);
        if (checkEmail.isPresent()) {
            throw new IllegalArgumentException("중복된 Email 입니다.");
        }

        // 사용자 ROLE 확인
        UserRoleEnum role = UserRoleEnum.USER;
        if (requestDto.isAdmin()) {
            if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        // 사용자 등록
        User user = new User(username, password, email, role);
        userRepository.save(user);
    }
}

repository 패키지를 만들고 UserRepository 인터페이스를 만든다.

package com.sparta.springauth.repository;

import com.sparta.springauth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long>{

}

User는 아까 그 클래스이다. @Table을 이용해 데이터베이스 테이블을 만들던 클래스의 이름

 

다시 UserController로 돌아온다.

 

UserController

생성자를 통해 UserService Bean을 주입받는다.

userService를 통해 회원가입이 성공하면 로그인을 하라는 뜻에서 로그인 페이지로 리다이렉트 해준다.

 

그리고 UserService의 ADMIN_TOKEN에 대해 알아보자

UserService

일반 사용자인지 관리자인지 구분하기 위해 만들어 둔것이다. 관리자 권한을 줄 때 이 토큰을 사용해서 관리자인지 아닌지 확인할것이다. 

그런데 실제로 관리자 권한을 줄 때 이런식으로 권한을 주지 않는다. 

현업에서는 관리자 권한을 부여할 수 있는 관리자 페이지를 따로 구현을 하거나 승인자에 의해서 결재하는 과정으로 구현을 한다.

현재는 간단하게 하기 위해 토큰을 사용한다.

UserService 클래스

 Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);

UserRepository 인터페이스에 findByUsername, findByEmail 메서드 만들어주기

User 클래스에 생성자를 만들어준다.

 

이렇게 스프링 서버를 실행하고 회원가입을 하면 정상적으로 데이터베이스에 데이터가 들어간다.