diff --git a/build.gradle b/build.gradle index 7d1de94..26b3122 100644 --- a/build.gradle +++ b/build.gradle @@ -24,12 +24,17 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.mindrot:jbcrypt:0.4' } tasks.named('test') { diff --git a/data/users.json b/data/users.json new file mode 100644 index 0000000..8f82d7a --- /dev/null +++ b/data/users.json @@ -0,0 +1,9 @@ +{ + "user123" : { + "id" : "user123", + "password" : "$2a$10$T5DRf1u9w4yABCuA9rbVFO3ig57iC/f5LdowvvoaNTXhlsADCoivW", + "email" : "user@example.com", + "nickname" : "사용자닉네임", + "createdAt" : 1742879450.367679500 + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/sbblogsystem/controller/UserController.java b/src/main/java/com/sprint/mission/sbblogsystem/controller/UserController.java new file mode 100644 index 0000000..e448109 --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/controller/UserController.java @@ -0,0 +1,40 @@ +package com.sprint.mission.sbblogsystem.controller; + +import com.sprint.mission.sbblogsystem.dto.LoginRequest; +import com.sprint.mission.sbblogsystem.dto.UserRegisterRequest; +import com.sprint.mission.sbblogsystem.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/users") +public class UserController { + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @PostMapping("/register") + public ResponseEntity> register(@RequestBody UserRegisterRequest request){ + userService.register(request); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "회원가입이 완료되었습니다."); + + return ResponseEntity.ok(response); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request){ + String token = userService.login(request); + Map response = new HashMap<>(); + response.put("success", true); + response.put("token", token); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/domain/User.java b/src/main/java/com/sprint/mission/sbblogsystem/domain/User.java new file mode 100644 index 0000000..cff796a --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/domain/User.java @@ -0,0 +1,23 @@ +package com.sprint.mission.sbblogsystem.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor + +public class User { + private String id; + private String password; + private String email; + private String nickname; + private Instant createdAt; + +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/dto/LoginRequest.java b/src/main/java/com/sprint/mission/sbblogsystem/dto/LoginRequest.java new file mode 100644 index 0000000..f023401 --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/dto/LoginRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.sbblogsystem.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class LoginRequest { + private String id; + private String password; +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/dto/UserRegisterRequest.java b/src/main/java/com/sprint/mission/sbblogsystem/dto/UserRegisterRequest.java new file mode 100644 index 0000000..aa62c68 --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/dto/UserRegisterRequest.java @@ -0,0 +1,15 @@ +package com.sprint.mission.sbblogsystem.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class UserRegisterRequest { + private String id; + private String password; + private String email; + private String nickname; +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/repository/UserRepository.java b/src/main/java/com/sprint/mission/sbblogsystem/repository/UserRepository.java new file mode 100644 index 0000000..c24b68d --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.sprint.mission.sbblogsystem.repository; + +import com.sprint.mission.sbblogsystem.domain.User; + +import java.util.Optional; + +public interface UserRepository { + + Optional findById(String id); + Optional findByNickname(String nickname); + boolean existsById(String id); + boolean existsByNickname(String nickname); + void save(User user); +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/repository/file/FileUserRepository.java b/src/main/java/com/sprint/mission/sbblogsystem/repository/file/FileUserRepository.java new file mode 100644 index 0000000..3b3987b --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/repository/file/FileUserRepository.java @@ -0,0 +1,78 @@ +package com.sprint.mission.sbblogsystem.repository.file; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.sbblogsystem.domain.User; +import com.sprint.mission.sbblogsystem.repository.UserRepository; +import org.springframework.stereotype.Repository; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.io.File; +import java.io.IOException; +import java.util.*; + +@Repository +public class FileUserRepository implements UserRepository { + + private static final String FILE_PATH = "data/users.json"; + private final Map userMap = new HashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()); + + public FileUserRepository() { + loadFromFile(); + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(userMap.get(id)); + } + + @Override + public Optional findByNickname(String nickname) { + return userMap.values().stream() + .filter(user -> user.getNickname().equals(nickname)) + .findFirst(); + } + + @Override + public boolean existsById(String id) { + return userMap.containsKey(id); + } + + @Override + public boolean existsByNickname(String nickname) { + return userMap.values().stream() + .anyMatch(user -> user.getNickname().equals(nickname)); + } + + @Override + public void save(User user) { + userMap.put(user.getId(), user); + saveToFile(); + } + + private void loadFromFile() { + File file = new File(FILE_PATH); + if (file.exists()){ + try { + Map loaded = objectMapper.readValue(file, new TypeReference<>() { + }); + userMap.clear(); + userMap.putAll(loaded); + } catch (IOException e) { + throw new RuntimeException("사용자 파일 로드 실패", e); + } + } + } + + private void saveToFile() { + File file = new File(FILE_PATH); + file.getParentFile().mkdirs(); + try { + objectMapper.writerWithDefaultPrettyPrinter().writeValue(file, userMap); + } catch (IOException e) { + throw new RuntimeException("사용자 파일 저장 실패", e); + } + } +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/service/UserService.java b/src/main/java/com/sprint/mission/sbblogsystem/service/UserService.java new file mode 100644 index 0000000..40801a3 --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/service/UserService.java @@ -0,0 +1,75 @@ +package com.sprint.mission.sbblogsystem.service; + +import com.sprint.mission.sbblogsystem.domain.User; +import com.sprint.mission.sbblogsystem.dto.LoginRequest; +import com.sprint.mission.sbblogsystem.dto.UserRegisterRequest; +import com.sprint.mission.sbblogsystem.repository.UserRepository; +import com.sprint.mission.sbblogsystem.util.ValidationUtil; +import com.sprint.mission.sbblogsystem.util.JwtUtil; +import org.apache.el.util.Validation; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Service +public class UserService { + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + public UserService(UserRepository userRepository, JwtUtil jwtUtil) { + this.userRepository = userRepository; + this.jwtUtil = jwtUtil; + } + + public void register(UserRegisterRequest request) { + if (!ValidationUtil.isValidId(request.getId())) { + throw new IllegalArgumentException("ID는 6~30자여야 합니다."); + } + if (!ValidationUtil.isValidPassword(request.getPassword())){ + throw new IllegalArgumentException("비밀번호는 12~50자이며, 영문/숫자/특수문자를 각각 2자 이상 포함해야 합니다."); + } + if (!ValidationUtil.isValidEmail(request.getEmail())){ + throw new IllegalArgumentException("이메일 형식이 유효하지 않거나 100자 초과입니다."); + } + if (!ValidationUtil.isValidNickname(request.getNickname())){ + throw new IllegalArgumentException("닉네임은 50자 이하여야 합니다."); + } + + if (userRepository.existsById(request.getId())){ + throw new IllegalArgumentException("이미 사용 중인 ID입니다."); + } + + if (userRepository.existsByNickname(request.getNickname())){ + throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); + } + + String hashedPassword = BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()); + + User user = new User( + request.getId(), + hashedPassword, + request.getEmail(), + request.getNickname(), + Instant.now() + ); + + userRepository.save(user); + + } + + public String login(LoginRequest request) { + User user = userRepository.findById(request.getId()) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + if (!BCrypt.checkpw(request.getPassword(), user.getPassword())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + + return jwtUtil.generateToken(user.getId()); + } + + public boolean existsById(String userId) { + return userRepository.findById(userId).isPresent(); + } +} diff --git a/src/main/java/com/sprint/mission/sbblogsystem/util/JwtUtil.java b/src/main/java/com/sprint/mission/sbblogsystem/util/JwtUtil.java new file mode 100644 index 0000000..00fd374 --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/util/JwtUtil.java @@ -0,0 +1,73 @@ +package com.sprint.mission.sbblogsystem.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtil { + private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); + + // 토큰에서 사용자 ID 추출 + public String extractUserId(String token) { + return extractClaim(token, Claims::getSubject); + } + + // 모든 클레임 추출 + public Claims extractAllClaims(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + } + + // 특정 클레임 추출 + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + // 토큰 생성 + public String generateToken(String userId) { + Map claims = new HashMap<>(); + return createToken(claims, userId); + } + + // 토큰 생성 (내부 메서드) + private String createToken(Map claims, String subject) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + // 무한 유효기간 대신 매우 긴 유효기간 설정 (예: 100년) + .setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 365 * 100)) + .signWith(key) + .compact(); + } + + // 토큰 유효성 검증 + public Boolean validateToken(String token) { + try { + return !isTokenExpired(token); + } catch (Exception e) { + return false; + } + } + + // 토큰 만료 확인 + private Boolean isTokenExpired(String token) { + final Date expiration = extractExpiration(token); + return expiration.before(new Date()); + } + + // 만료일 추출 + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } +} + diff --git a/src/main/java/com/sprint/mission/sbblogsystem/util/ValidationUtil.java b/src/main/java/com/sprint/mission/sbblogsystem/util/ValidationUtil.java new file mode 100644 index 0000000..e672e16 --- /dev/null +++ b/src/main/java/com/sprint/mission/sbblogsystem/util/ValidationUtil.java @@ -0,0 +1,39 @@ +package com.sprint.mission.sbblogsystem.util; + +import java.util.regex.Pattern; + +public class ValidationUtil { + private static final Pattern EMAIL_REGEX = + Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"); + + + public static boolean isValidId(String id){ + return id != null && id.length() >= 6 && id.length() <= 30; + } + + public static boolean isValidPassword(String password){ + if(password == null || password.length() < 12 || password.length()>50) { + return false; + } + + int letters = 0 ; + int digits = 0 ; + int specialChars = 0 ; + + for ( char ch : password.toCharArray()){ + if (Character.isLetter(ch)) letters++; + else if(Character.isDigit(ch)) digits++; + else if("!@#$%^&*".indexOf(ch) != -1) specialChars++; + } + + return letters >= 2 && digits >= 2 && specialChars >= 2; + } + + public static boolean isValidEmail(String email) { + return email != null && email.length() <= 100 && EMAIL_REGEX.matcher(email).matches(); + } + + public static boolean isValidNickname(String nickname) { + return nickname != null && nickname.length() <= 50; + } +}