build.gradle
BCrypt
BCrypt는 패스워드를 안전하게 암호화하기 위해 널리 사용되는 해시 함수 알고리즘입니다. org.mindrot.jbcrypt.BCrypt는 이 알고리즘을 자바에서 쉽게 사용할 수 있도록 제공하는 라이브러리로, 비밀번호를 해시하여 저장하고, 사용자가 로그인할 때 입력한 비밀번호가 기존 해시와 일치하는지 안전하게 비교할 수 있도록 hashpw()와 checkpw() 메서드를 제공합니다. 이 방식은 단방향 암호화로 복호화가 불가능하고, salt을 자동으로 적용하여 동일한 비밀번호라도 해시값이 달라지기 때문에 무차별 대입 공격(Brute-force)과 레인보우 테이블 공격에 강한 보안성을 갖습니다.
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// mysql
runtimeOnly 'com.mysql:mysql-connector-j'
// mybatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
// bcrypt
implementation 'org.mindrot:jbcrypt:0.4'
// JSON ,JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
...
application.properties
spring.application.name=restapi
spring.datasource.url=jdbc:mysql://localhost:3306/spring?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.koreait.restful.dto
MemberDTO.java
package com.koreait.restapi.dto;
import lombok.Data;
@Data
public class MemberDTO {
private int id;
private String username;
private String password;
private String name;
}
MemberMapper.java
package com.koreait.restapi.mapper;
import com.koreait.restful.dto.MemberDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface MemberMapper {
MemberDTO findByUsername(@Param("username") String username);
void save(MemberDTO member);
void update(MemberDTO member);
}
MemberMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.koreait.restapi.mapper.MemberMapper">
<select id="findByUsername" resultType="com.koreait.restful.dto.MemberDTO">
SELECT * FROM member WHERE username = #{username}
</select>
<insert id="save" parameterType="com.koreait.restful.dto.MemberDTO">
INSERT INTO member (username, password, name)
VALUES (#{username}, #{password}, #{name})
</insert>
<update id="update" parameterType="com.koreait.restful.dto.MemberDTO">
UPDATE member
SET
<if test="password != null and password != ''">
password = #{password},
</if>
name = #{name}
WHERE id = #{id}
</update>
</mapper>
MemberController.java
package com.koreait.restapi.controller;
import com.koreait.restapi.dto.MemberDTO;
import com.koreait.restapi.jwt.JwtUtil;
import com.koreait.restapi.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@CrossOrigin(origins = "http://localhost:5173", allowCredentials = "true") // 프론트 주소
@RestController
@RequestMapping("/api/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService service;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody MemberDTO loginRequest) {
String token = service.login(loginRequest.getUsername(), loginRequest.getPassword());
if (token != null) {
return ResponseEntity.ok().body(token);
} else {
return ResponseEntity.status(401).body("로그인 실패");
}
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody MemberDTO member) {
service.register(member);
return ResponseEntity.ok("회원가입 성공");
}
@GetMapping("/info")
public ResponseEntity<?> getUserInfo(@RequestHeader("Authorization") String token) {
MemberDTO member = service.getUserInfoFromToken(token);
if (member != null) {
return ResponseEntity.ok(member);
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("사용자를 찾을 수 없습니다.");
}
}
@PutMapping("/update")
public ResponseEntity<?> update(@RequestHeader("Authorization") String token,
@RequestBody MemberDTO member) {
service.update(token, member);
return ResponseEntity.ok("회원정보 수정 성공");
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String token) {
service.logout(token);
return ResponseEntity.ok("로그아웃 성공 (클라이언트에서 토큰 삭제)");
}
}
JwtAuthFilter.java
package com.koreait.restapi.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
response.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
response.setHeader("Access-Control-Allow-Credentials", "true");
// 프리플라이트 OPTIONS 요청일 경우 바로 응답
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (!jwtUtil.isTokenValid(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
} else if (!request.getRequestURI().contains("login") && !request.getRequestURI().contains("register")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(request, response);
}
}
JwtUtil.java
package com.koreait.restful.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
@Component
public class JwtUtil {
private final String SECRET = "MySuperSecretKeyForJWTTokenWhichIsVerySecure12345";
private final long EXPIRATION = 1000 * 60 * 60; // 1시간
private final Key key = Keys.hmacShaKeyFor(SECRET.getBytes());
public String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String getUsernameFromToken(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}
MemberService.java
package com.koreait.restful.service;
import com.koreait.restful.dto.MemberDTO;
public interface MemberService {
String login(String username, String password);
void register(MemberDTO member);
void update(String token, MemberDTO updatedMember);
void logout(String token); // optional
MemberDTO getUserInfoFromToken(String token);
}
MemberServiceImpl.java
package com.koreait.restful.service;
import com.koreait.restful.dto.MemberDTO;
import com.koreait.restful.jwt.JwtUtil;
import com.koreait.restful.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final MemberMapper mapper;
private final JwtUtil jwtUtil;
@Override
public String login(String username, String password) {
MemberDTO member = mapper.findByUsername(username);
if (member != null && BCrypt.checkpw(password, member.getPassword())) {
return jwtUtil.generateToken(member.getUsername());
}
return null;
}
@Override
public void register(MemberDTO member) {
String hashed = BCrypt.hashpw(member.getPassword(), BCrypt.gensalt());
member.setPassword(hashed);
mapper.save(member);
}
@Override
public void update(String token, MemberDTO updatedMember) {
String jwt = token.replace("Bearer ", "");
String username = jwtUtil.getUsernameFromToken(jwt);
MemberDTO original = mapper.findByUsername(username);
if (original != null) {
if (StringUtils.hasText(updatedMember.getPassword())) {
String hashed = BCrypt.hashpw(updatedMember.getPassword(), BCrypt.gensalt());
original.setPassword(hashed);
}
if (StringUtils.hasText(updatedMember.getName())) {
original.setName(updatedMember.getName());
}
mapper.update(original);
}
}
@Override
public MemberDTO getUserInfoFromToken(String token) {
String jwt = token.replace("Bearer ", "");
String username = jwtUtil.getUsernameFromToken(jwt);
MemberDTO member = mapper.findByUsername(username);
if (member != null) {
member.setPassword(null);
}
return member;
}
@Override
public void logout(String token) {
// Stateless 방식이라 클라이언트에서 토큰 제거하면 됨
}
}
'Backend > String' 카테고리의 다른 글
JPA (0) | 2025.06.30 |
---|---|
Thymeleaf (0) | 2025.06.18 |
스프링 vs 스프링부트 (0) | 2025.06.18 |