Post

[Day31] 쇼핑몰 실습 - 토큰

[Day31] 쇼핑몰 실습 - 토큰

JSESSIONID가 요청 시 자동으로 포함되지 않는 문제와 해결 방법

프론트엔드 (:5500)에서 백엔드 (:8080)로 로그인 요청을 보낼 때 JSESSIONID를 받아 저장했지만, 이후 요청에서 쿠키가 자동으로 포함되지 않는 문제 발생.

방안 1. 구조 변경

  • 프론트엔드를 백엔드 서버에서 서빙하여 동일한 도메인/포트를 사용하도록 변경.

방안 2. 쿠키 설정 변경 (토큰)

데이터베이스 테이블 생성

1
2
3
4
5
6
7
use ureca;

CREATE TABLE `ureca`.`login` (
  `email` VARCHAR(50) NOT NULL primary key,
  `token` VARCHAR(256) NOT NULL unique,
  `logintime` TIMESTAMP NOT NULL DEFAULT current_timestamp
);

OpenCrypt.java (암호화 유틸리티)

  • SHA-256을 이용해 이메일을 해싱하여 토큰을 생성.
  • AES-256 암호화를 지원.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public class OpenCrypt {

    public static byte[] getSHA256(String source, String salt) {
        byte byteData[]=null;
        try{
            MessageDigest md = MessageDigest.getInstance("SHA-256"); 
            md.update(source.getBytes()); 
            md.update(salt.getBytes()); 
            byteData= md.digest();  
            System.out.println("원문: "+source+ "   SHA-256: "+
                                    byteData.length+","+byteArrayToHex(byteData));
        }catch(NoSuchAlgorithmException e){
            e.printStackTrace(); 
        }
        return byteData;
    }
	 	 
    public static byte[] generateKey(String algorithm,int keySize) throws NoSuchAlgorithmException {    
        KeyGenerator keyGenerator = KeyGenerator.getInstance(algorithm);	 
        keyGenerator.init(keySize);
        SecretKey key = keyGenerator.generateKey();
        return key.getEncoded();	 
    }	

    public static String aesEncrypt(String msg, byte[] key) throws Exception {
        SecretKeySpec skeySpec = new SecretKeySpec(key, "AES");
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            String iv = "AAAAAAAAAAAAAAAA";
            cipher.init(Cipher.ENCRYPT_MODE, 
                        skeySpec,
                        new IvParameterSpec(iv.getBytes()));        
            byte[] encrypted = cipher.doFinal(msg.getBytes());     
            return  byteArrayToHex(encrypted);
    }
	 
    public static String aesDecrypt(String msg,byte[] key ) throws Exception {
        SecretKeySpec skeySpec = new SecretKeySpec(key, "AES");
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        String iv = "AAAAAAAAAAAAAAAA";
        cipher.init(Cipher.DECRYPT_MODE, 
                    skeySpec,
                    new IvParameterSpec(iv.getBytes()));  
        byte[] encrypted = hexToByteArray(msg);
        byte[] original = cipher.doFinal(encrypted);  
        return new String(original); 
    }
	 
    public static byte[] hexToByteArray(String hex) {
        if (hex == null || hex.length() == 0) {
            return null;
        }
        
        byte[] ba = new byte[hex.length() / 2];
        for (int i = 0; i < ba.length; i++) {
            ba[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
        }
        return ba;
    }
		 
    // byte[] to hex
    public static String byteArrayToHex(byte[] ba) {
        if (ba == null || ba.length == 0) {
            return null;
        }
        
        StringBuffer sb = new StringBuffer(ba.length * 2);
        String hexNumber;
        for (int x = 0; x < ba.length; x++) {
            hexNumber = "0" + Integer.toHexString(0xff & ba[x]);
        
            sb.append(hexNumber.substring(hexNumber.length() - 2));
        }
        return sb.toString();
    } 	 
}

MemberService.java (토큰 로그인 로직)

  1. 사용자 인증 후 이메일을 기반으로 토큰 생성
  2. 토큰을 login 테이블에 저장
  3. 토큰을 응답하여 클라이언트가 저장 후 사용하도록 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.shop.cafe.service;

import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.shop.cafe.dao.LoginDao;
import com.shop.cafe.dao.MemberDao;
import com.shop.cafe.dto.Login;
import com.shop.cafe.dto.Member;
import com.shop.cafe.util.OpenCrypt;

@Service
public class MemberService {
	@Autowired
	MemberDao memberDao;
	
	@Autowired
	LoginDao loginDao;

	public Login tokenLogin(Member m) throws Exception {
		m=memberDao.login(m);
		if(m!=null) {
			String nickname=m.getNickname();
			if(nickname!=null && !nickname.trim().equals("")) {
				//member table에서 email과 pwd가 확인된 상황 즉 login ok
				String email=m.getEmail();
				//1. salt를 생성한다
				String salt=UUID.randomUUID().toString();
				System.out.println("salt:"+salt);
				//2. email을 hashing 한다
				byte[] originalHash=OpenCrypt.getSHA256(email, salt);
				//3. db에 저장하기 좋은 포맷으로 인코딩한다
				String myToken=OpenCrypt.byteArrayToHex(originalHash);
				System.out.println("myToken : "+myToken);
				
				//4. login table에 token 저장
				Login loginInfo=new Login(email, myToken, nickname, null);
				loginDao.insertToken(loginInfo);
				return loginInfo;
			}
		}
		return null;		 
	}
	
	public Member login(Member m) throws Exception {
		return memberDao.login(m);
	}
	
	public void insertMember(Member m) throws Exception{
		memberDao.insertMember(m);
	}
	
	public void updateMember(Member m) throws Exception{
		memberDao.updateMember(m);
	}
	
	public void deleteMember(String email) throws Exception{
		memberDao.deleteMember(email);
	}

	public void logout(String authorization) throws Exception {
		loginDao.deleteToken(authorization);	
	}
}

Login.java (DTO)

  • 로그인 정보를 저장하는 DTO
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.shop.cafe.dto;

import java.util.Date;

public class Login {
	private String email, token, nickname;
	private Date loginTime;
	
	public Login(String email, String token, String nickname, Date loginTime) {
		super();
		this.email = email;
		this.token = token;
		this.nickname = nickname;
		this.loginTime = loginTime;
	}

	public Login() {
		super();
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}

	public String getToken() {
		return token;
	}

	public void setToken(String token) {
		this.token = token;
	}

	public String getNickname() {
		return nickname;
	}

	public void setNickname(String nickname) {
		this.nickname = nickname;
	}

	public Date getLoginTime() {
		return loginTime;
	}

	public void setLoginTime(Date loginTime) {
		this.loginTime = loginTime;
	}

	@Override
	public String toString() {
		return "Login [email=" + email + ", token=" + token + ", nickname=" + nickname + ", loginTime=" + loginTime
				+ "]";
	}
}

LoginDao.java (토큰 저장/삭제)

  • 토큰을 DB에 저장하고 삭제하는 기능을 제공.
1
2
3
4
5
6
7
8
9
10
package com.shop.cafe.dao;
import org.apache.ibatis.annotations.Mapper;
import com.shop.cafe.dto.Login;

@Mapper
public interface LoginDao {	
	public void insertToken(Login login) throws Exception;

	public void deleteToken(String token) throws Exception;
}

login.xml (MyBatis 설정)**

  • insertToken: 로그인 시 토큰을 저장.
  • deleteToken: 로그아웃 시 토큰 삭제.
1
2
3
4
5
6
7
8
9
<mapper namespace="com.shop.cafe.dao.LoginDao">
    <insert id="insertToken" parameterType="Login">
        insert into login(email, token) values(#{email},#{token})
    </insert>
    
    <delete id="deleteToken" parameterType="String">
        delete from login where token=#{token}
    </delete>
</mapper>

MemberController.java (API 엔드포인트)

  • 로그인 시 토큰을 생성하여 응답
  • 이후 요청은 Authorization 헤더에 토큰을 담아 인증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@CrossOrigin("http://127.0.0.1:5500/")
@RestController
public class MemberController {
    
    @PostMapping("tokenLogin")
    public Map<String, String> tokenLogin(@RequestBody Member m) {
        Map<String, String> responseMap = new HashMap<>();
        try {
            Login loginInfo = memberService.tokenLogin(m);
            if (loginInfo != null) {
                responseMap.put("nickname", loginInfo.getNickname());
                responseMap.put("Authorization", loginInfo.getToken());
            } else {
                responseMap.put("msg", "다시 로그인 해주세요");
            }
        } catch (Exception e) {
            responseMap.put("msg", "다시 로그인 해주세요");
        }
        return responseMap;
    }

    @PostMapping("logout")
    public void logout(@RequestHeader String authorization) {
        try {
            memberService.logout(authorization);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

프론트엔드 axios 설정 변경

백엔드가 쿠키를 유지하도록 허용했다면, 프론트엔드에서도 요청 시 withCredentials: true 옵션을 추가해야 함.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
document.getElementById("loginBtn").addEventListener("click", async () => {  
  const email = document.getElementById("loginEmail").value;
  const pwd = document.getElementById("loginPwd").value;
  const data = {  email, pwd };
  const response=await axios.post("http://localhost:8080/tokenLogin" , data);
  
  document.getElementById("loginSpan").innerHTML=`${response.data.nickname}  
  <button class="btn btn-danger btn-sm" id="logoutBtn">Logout</button>`;
  const token = response.data.Authorization;

  sessionStorage.setItem('Authorization', token);
  sessionStorage.setItem('nickname', response.data.nickname);
  axios.defaults.headers.common['Authorization'] = token; // Authorization 헤더 설정

  const modal = bootstrap.Modal.getInstance(document.getElementById("loginModal"));
  loginModal.setAttribute("aria-hidden", "true");
  modal.hide();
});

const Authorization = sessionStorage.getItem("Authorization");
const nickname = sessionStorage.getItem("nickname");
if (Authorization && nickname) {
  axios.defaults.headers.common['Authorization'] = Authorization; // Authorization 헤더 설정
  document.getElementById("loginSpan").innerHTML = `${nickname}  
  <button class="btn btn-danger btn-sm" id="logoutBtn">Logout</button>`;
}

document.getElementById("loginSpan").addEventListener("click", async (event)=>{
  if(event.target.id=='logoutBtn'){       
      await axios.post("http://localhost:8080/logout");
      sessionStorage.removeItem("nickname");
      sessionStorage.removeItem("Authorization");
      axios.defaults.headers.common['Authorization'] = ''; // Authorization 헤더에서 삭제       
      window.location.reload();
  }
});

결과

  • JSESSIONID 대신 토큰을 사용하여 인증을 관리
  • 클라이언트는 응답받은 Authorization 토큰을 이후 요청의 헤더에 포함하여 인증을 수행.
  • DB에 저장된 토큰을 활용해 사용자의 로그인 상태를 유지할 수 있음.


Request HeaderRequest Body의 차이

1️⃣ Request Header (요청 헤더)

  • 요청에 대한 메타데이터(부가 정보)를 포함
  • 요청의 형식, 인증 정보, 캐싱 정책, 클라이언트 정보 등을 전달
  • 주로 키-값 쌍 (Key-Value Pair) 형태로 표현됨
1
2
3
4
POST /api/user HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: Bearer token123
  • Content-Type: 요청 본문의 데이터 형식을 지정 (예: application/json)
  • Authorization: 인증 정보를 전달 (예: JWT 토큰)
  • User-Agent: 클라이언트(브라우저 또는 앱)의 정보

2️⃣ Request Body (요청 본문)

  • 요청의 실제 데이터(payload) 를 포함
  • POST, PUT, PATCH 등의 요청에서 주로 사용
  • GET 요청에는 보통 본문이 없음
1
2
3
4
{
    "username": "yeeun",
    "password": "secure123"
}
  • 요청의 본문에는 실제 데이터를 담아 서버로 전달함
  • Content-Type: application/json 헤더가 있어야 JSON 데이터를 보낼 수 있음

정리

구분Request HeaderRequest Body
역할요청의 메타데이터요청의 실제 데이터
포함 정보인증 정보, 콘텐츠 타입, 쿠키 등사용자 입력 데이터, 파일 등
형식키-값 쌍 (Key-Value)JSON, XML, FormData 등

Teamwork Challenge - 장바구니 담기

백엔드 (BACK)

데이터베이스 (MySQL) : cart 테이블 생성

1
2
3
4
5
create table cart(
cartNo int primary key,
token varchar(256) not null,
prodcode int not null,
);

데이터 모델(DTO) : Cart.java

  • member 테이블과 매핑되는 객체
  • 회원 정보를 저장하는 필드 및 getter, setter 포함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.shop.cafe.dto;

public class Cart {
	private int cartNo,prodcode;
	private String token;
	public Cart(int cartNo, int prodcode, String token) {
		super();
		this.cartNo = cartNo;
		this.prodcode = prodcode;
		this.token = token;
	}
	public Cart() {
		super();
		// TODO Auto-generated constructor stub
	}
	public int getCartNo() {
		return cartNo;
	}
	public void setCartNo(int cartNo) {
		this.cartNo = cartNo;
	}
	public int getProdcode() {
		return prodcode;
	}
	public void setProdcode(int prodcode) {
		this.prodcode = prodcode;
	}
	public String getToken() {
		return token;
	}
	public void setToken(String token) {
		this.token = token;
	}
	@Override
	public String toString() {
		return "Cart [cartNo=" + cartNo + ", prodcode=" + prodcode + ", token=" + token + "]";
	}	
}

데이터 접근 계층(DAO) : CartDao.java

🔹 데이터베이스와 연결하여 회원 정보를 저장하는 역할

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.shop.cafe.dao;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;

import com.shop.cafe.dto.Cart;
import com.shop.cafe.dto.Product;

@Mapper
public interface CartDao {
	public void addCart(Cart c);
}

서비스 계층 (Service) : CartService.java

🔹 DAO를 호출하여 회원 정보를 저장하는 역할

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.shop.cafe.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.shop.cafe.dao.CartDao;
import com.shop.cafe.dto.Cart;
import com.shop.cafe.dto.Product;


@Service
public class CartService {
	@Autowired
	CartDao cartDao;

	public void addCart(Cart c) throws Exception{
		cartDao.addCart(c);
	}
}

컨트롤러 (Controller) : CartController.java

🔹 REST API 엔드포인트를 제공하여 회원가입 요청을 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.shop.cafe.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

import com.shop.cafe.dto.Cart;
import com.shop.cafe.service.CartService;

@RestController
@CrossOrigin("http://127.0.0.1:5500/")
public class CartController {
	@Autowired
	CartService cartService;
	
	@PostMapping("addCart")
	public void addCart(@RequestBody Cart c, @RequestHeader String authorization) {
		try {
			if(authorization.equals(c.getToken())) {
				cartService.addCart(c);
			} else {
				System.out.println("다름");
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

프론트엔드 (FRONT)

Cart.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let prodcode;

document.addEventListener("click", async (event) => {
  if (event.target.classList.contains("cart-btn")) {
    prodcode = event.target.dataset.productId;
    try {
      const reviewsResponse = await axios.post(
        "http://localhost:8080/addCart",
        { prodcode: prodcode, token: sessionStorage.Authorization }
      );
      const cart = reviewsResponse.data;
    } catch (error) {
      console.error("에러");
    }
  }
});


This post is licensed under CC BY 4.0 by the author.