[Day34] 쇼핑몰 실습 - JWT & Salt
[Day34] 쇼핑몰 실습 - JWT & Salt
Hash & Salt
Salt : 사용자마다 고유한 난수를 생성하여 해싱할 때 추가
Hashing : 단방향 암호화 방식으로 저장된 비밀번호를 보호
- 목적: 데이터 무결성 검증 및 보안을 위해 해시 값에 추가적인 무작위 데이터(Salt)를 더하는 것
- 해시는 주어진 데이터를 고정된 길이의 값으로 변환하는 방식
- 같은 입력값이면 항상 같은 해시값이 나오며, 이를 통해 데이터가 변조되지 않았음을 확인할 수 있음
- 비밀번호 저장 시 주로 사용되며, 직접 비밀번호를 저장하지 않고 해시 값을 저장함
- 비밀번호를 해싱하면 원래 값으로 복원할 수 없음(단방향 암호화)
💡 단순 해싱은 같은 입력값이면 항상 같은 해시 값이 나와서 Brute Force Attack이나 레인보우 테이블 공격(Rainbow Table Attack)에 취약함
백엔드 (BACK)
📌 Salt 저장 테이블 생성 (MySQL)
1
2
3
4
5
6
DROP TABLE IF EXISTS saltInfo;
CREATE TABLE saltInfo (
email VARCHAR(50) PRIMARY KEY,
salt VARCHAR(256)
);
📌 DTO: SaltInfo.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SaltInfo {
private String email, salt;
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getSalt() { return salt; }
public void setSalt(String salt) { this.salt = salt; }
public SaltInfo(String email, String salt) {
super();
this.email = email;
this.salt = salt;
}
public SaltInfo() { super(); }
@Override
public String toString() {
return "SaltInfo [email=" + email + ", salt=" + salt + "]";
}
}
📌 DAO: SaltDao.java
1
2
3
4
5
6
7
8
import org.apache.ibatis.annotations.Mapper;
import com.shop.cafe.dto.SaltInfo;
@Mapper
public interface SaltDao {
public void insertSalt(SaltInfo saltInfo) throws Exception;
public SaltInfo selectSalt(String email) throws Exception;
}
insertSalt(SaltInfo saltInfo)
: salt 저장selectSalt(String email)
: 특정 email에 대한 salt 조회
📌 MyBatis 매핑 : salt.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.shop.cafe.dao.SaltDao">
<insert id="insertSalt" parameterType="SaltInfo">
INSERT INTO saltInfo (email, salt) VALUES (#{email}, #{salt})
</insert>
<select id="selectSalt" parameterType="String" resultType="SaltInfo">
SELECT * FROM saltInfo WHERE email=#{email}
</select>
</mapper>
📌 서비스 로직: MemberService.java
✅ 회원 가입 시 패스워드 해싱 후 저장
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
public void insertMember(Member m) throws Exception {
// 이메일 유효성 검사
String email = m.getEmail();
if (!isValidEmail(email)) {
throw new Exception("유효하지 않은 이메일 형식입니다.");
}
// 패스워드 유효성 검사
String pwd = m.getPwd();
if (!isValidPassword(pwd)) {
throw new Exception("패스워드는 8자리 이상이어야 하며, 특수문자와 숫자를 포함해야 합니다.");
}
// 패스워드 암호화
// 1. Salt 생성
String salt = UUID.randomUUID().toString();
System.out.println("salt: " + salt);
// 2. 비밀번호 해싱 (SHA-256 + Salt 적용)
byte[] originalHash = OpenCrypt.getSHA256(pwd, salt);
// 3. 해싱된 비밀번호를 Hex로 변환 (db에 저장하기 좋은 포맷)
String pwdHash = OpenCrypt.byteArrayToHex(originalHash);
System.out.println("pwdHash: " + pwdHash);
// 4. Salt 및 해싱된 비밀번호 저장
m.setPwd(pwdHash);
saltDao.insertSalt(new SaltInfo(email, salt));
memberDao.insertMember(m);
}
✅ 이메일 유효성 검사 메서드
1
2
3
4
5
6
7
8
9
10
11
12
private boolean isValidEmail(String email) {
// 이메일 패턴
String emailPattern = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$";
return Pattern.matches(emailPattern, email);
}
// 패스워드 유효성 검사 메서드
private boolean isValidPassword(String password) {
// 패스워드 패턴: 8자리 이상, 숫자 포함, 특수문자 포함
String passwordPattern = "^(?=.*[0-9])(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,}$";
return Pattern.matches(passwordPattern, password);
}
✅ 로그인 시 동일한 해싱을 수행하여 저장된 값과 비교
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
public Login tokenLogin(Member m) throws Exception {
String email = m.getEmail();
// 1. DB에서 해당 email의 salt 조회
SaltInfo saltInfo = saltDao.selectSalt(email);
// 2. 입력한 비밀번호 + salt로 다시 해싱
String pwd = m.getPwd();
byte[] pwdHash = OpenCrypt.getSHA256(pwd, saltInfo.getSalt());
String pwdHashHex = OpenCrypt.byteArrayToHex(pwdHash);
// 3. 해싱된 값과 DB 저장값 비교
m.setPwd(pwdHashHex);
m = memberDao.login(m);
if (m != null) {
// 1.로그인 성공 시 토큰 발급
String nickname = m.getNickname();
if(nickname!=null && !nickname.trim().equals("")) {
//1. salt를 생성한다
String salt = UUID.randomUUID().toString();
//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, new Date());
loginDao.insertToken(loginInfo);
return loginInfo;
}
}
return null;
}
프론트엔드 (FRONTEND)
📌 member.js
(회원 가입 유효성 검사)
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
const isValidEmail = (email) => {
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailPattern.test(email);
};
const isValidPassword = (password) => {
const passwordPattern = /^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$/;
return passwordPattern.test(password);
};
const checkNicknameDuplicate = async (nickname) => {
const response = await fetch(`/api/check-nickname?nickname=${nickname}`);
const data = await response.json();
return data.isDuplicate;
};
const registerUser = async (email, password, nickname) => {
if (!isValidEmail(email)) {
alert("유효한 이메일을 입력하세요.");
return;
}
if (!isValidPassword(password)) {
alert("비밀번호는 8자리 이상이며, 숫자와 특수문자를 포함해야 합니다.");
return;
}
const isDuplicate = await checkNicknameDuplicate(nickname);
if (isDuplicate) {
alert("이미 사용 중인 닉네임입니다.");
return;
}
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, nickname }),
});
if (response.ok) {
alert("회원가입 성공!");
} else {
alert("회원가입 실패.");
}
};
JWT
목적 : 인증 및 정보 전달
- JWT는 사용자의 인증 상태를 유지하거나 특정 정보를 안전하게 전달하기 위해 사용됨
- 서버가 클라이언트에게 발급한 후, 클라이언트는 이를 요청 시 포함하여 보냄
- JWT 내부에는 서명(Signature)이 있어 위변조 방지를 할 수 있음
- 일반적으로 로그인 인증 토큰으로 사용됨
백엔드 (BACK)
📌 pom.xml
에 jjwt
라이브러리 추가
1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
📌 JWT 유틸리티 클래스 : JwtTokenProvider.java
표준 포맷 jwt를 생성/확인 할 수 있는 유틸리티 클래스
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
package com.shop.cafe.util;
import java.util.Base64;
import java.util.Date;
import io.jsonwebtoken.*;
public class JwtTokenProvider {
private static String salt = Base64.getEncoder().encodeToString("솔트".getBytes());
// JWT 생성
public static String createToken(String data) {
Claims claims = Jwts.claims();
claims.put("nickname", data);
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + (1000L * 60 * 30))) // 30분 유효
.signWith(SignatureAlgorithm.HS256, salt)
.compact();
}
// JWT 유효성 검사 : 만료시간이 지났으면 true임. 그러므로 !을 붙여야함
public static boolean validateToken(String jwtToken) {
return !getInformation(jwtToken).getExpiration().before(new Date());
}
// JWT 정보 가져오기
public static Claims getInformation(String jwtToken) {
return Jwts.parser().setSigningKey(salt).parseClaimsJws(jwtToken).getBody();
}
}
📌 JWT를 이용한 회원 인증 처리 : MemberService.java
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
package com.shop.cafe.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.shop.cafe.dao.*;
import com.shop.cafe.dto.*;
import com.shop.cafe.util.JwtTokenProvider;
import com.shop.cafe.util.OpenCrypt;
import java.util.Date;
import java.util.UUID;
import java.util.regex.Pattern;
@Service
public class MemberService {
@Autowired
MemberDao memberDao;
@Autowired
LoginDao loginDao;
@Autowired
SaltDao saltDao;
// JWT 토큰을 이용한 로그인
public Login tokenLogin(Member m) throws Exception {
String email=m.getEmail();
//email로 salt를 찾아옴
SaltInfo saltInfo=saltDao.selectSalt(email);
//pwd에 salt를 더하여 암호화
String pwd=m.getPwd();
byte [] pwdHash=OpenCrypt.getSHA256(pwd, saltInfo.getSalt());
String pwdHashHex=OpenCrypt.byteArrayToHex(pwdHash);
m.setPwd(pwdHashHex);
// login
m=memberDao.login(m);
if (m != null) {
String nickname = m.getNickname();
if (nickname != null && !nickname.trim().isEmpty()) {
String jwtToken = JwtTokenProvider.createToken(nickname);
Login loginInfo = new Login(email, jwtToken, nickname, null);
loginDao.insertToken(loginInfo);
return loginInfo;
}
}
return null;
}
public void logout(String authorization) throws Exception {
loginDao.deleteToken(authorization);
}
}
📌 JWT를 활용한 장바구니 기능 : CartController.java
JWT를 이용하여 인증된 사용자만 장바구니에 추가 가능
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
@RestController
@CrossOrigin("http://127.0.0.1:5500/")
public class CartController {
@Autowired
MemberService memberService;
@Autowired
CartService cartService;
@PostMapping("addToCart")
public String addToCart(@RequestHeader String authorization, @RequestBody Cart cart) {
System.out.println(authorization);
System.out.println(cart);
try {
if(JwtTokenProvider.validateToken(authorization)) { //로그인 시간이 유효한 지 확인
Login loginInfo = memberService.checkToken(authorization); //email을 가져오기 위함
cart.setEmail(loginInfo.getEmail());
cart.setQuantity(1);
cartService.addToCart(cart);
return "ok";
}
return null;
} catch (Exception e) {
e.printStackTrace();
return "Error";
}
}
}
🔍 JWT vs Hashing 차이점
구분 | JWT | Hashing |
---|---|---|
목적 | 인증 및 정보 전달 | 데이터 보호 및 무결성 검증 |
방식 | 서명(Signature)과 함께 정보 포함 | 원본 데이터를 해시값으로 변환 |
복호화 가능 여부 | 가능(서명 검증) | 불가능(단방향) |
사용 예시 | 로그인 토큰, API 인증 | 비밀번호 저장, 데이터 무결성 검증 |
1
2
3
4
Summary
- JWT는 클라이언트와 서버 간 인증을 위해 사용
- Hashing은 비밀번호 저장, 데이터 검증 등을 위해 사용
- 비밀번호를 저장할 때 JWT를 쓰면 안 되고, 반대로 JWT를 해싱하면 인증 기능을 잃음!
END
This post is licensed under CC BY 4.0 by the author.