From 97c941cf3d5e0a52433e863f30205d6c06d29943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B4=80=ED=9D=AC?= <85067003+limehee@users.noreply.github.com> Date: Sun, 21 Jul 2024 17:30:11 +0900 Subject: [PATCH] =?UTF-8?q?JWT=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(User): 회원 정보에 권한 추가 * feat(User): API Docs 설정 추가 * feat(User): 회원가입 API 추가 * feat(Login): Redis에 JWT 정보를 기록 * feat(Login): JWT 로그인 구현 * feat(Login): JWT 재발급 구현 * refactor(Login): Device Tag를 이용하여 자동 회원가입 및 로그인이 가능하도록 처리 * refactor(User): 회원가입 API 제거 * refactor(Login): User 테이블 컬럼명(id->device_tag) 변경, 회원가입시 UUID를 통해 랜덤 비밀번호를 생성하도록 설정 * refactor(Login): 토큰을 헤더에 담아 반환하도록 변경 * refactor(User): 회원가입시 비밀번호를 지정 가능하게 변경 * refactor(User): 비밀번호 생성 로직 강화 * refactor(Test): 미사용 테스트 코드 제거 --- build.gradle | 7 +- .../application/service/LoginService.java | 11 ++ .../application/service/LoginServiceImpl.java | 53 +++++++ .../application/service/PasswordService.java | 5 + .../service/PasswordServiceImpl.java | 17 ++ .../service/RedisTokenService.java | 14 ++ .../service/RedisTokenServiceImpl.java | 34 ++++ .../application/service/UserService.java | 14 ++ .../application/service/UserServiceImpl.java | 37 +++++ .../api/domain/domain/model/RedisToken.java | 43 +++++ .../stempo/api/domain/domain/model/Role.java | 15 ++ .../stempo/api/domain/domain/model/User.java | 28 ++++ .../repository/RedisTokenRepository.java | 14 ++ .../domain/repository/UserRepository.java | 14 ++ .../domain/persistence/entity/UserEntity.java | 8 +- .../persistence/mappper/UserMapper.java | 23 +++ .../repository/RedisTokenJpaRepository.java | 13 ++ .../repository/RedisTokenRepositoryImpl.java | 30 ++++ .../repository/UserJpaRepository.java | 7 + .../repository/UserRepositoryImpl.java | 34 ++++ .../domain/presentation/LoginController.java | 47 ++++++ .../presentation/dto/response/TokenInfo.java | 22 +++ .../auth/exception/TokenForgeryException.java | 15 ++ .../exception/TokenNotFoundException.java | 15 ++ .../exception/TokenValidateException.java | 15 ++ .../auth/filter/JwtAuthenticationFilter.java | 52 ++++++ .../api/global/auth/jwt/JwtTokenProvider.java | 150 ++++++++++++++++++ .../api/global/common/dto/ApiResponse.java | 43 +++++ .../api/global/config/OpenApiConfig.java | 49 ++++++ .../global/config/PasswordEncoderConfig.java | 15 ++ .../api/global/config/SecurityConfig.java | 40 +++++ .../global/exception/NotFoundException.java | 8 + .../stempo/api/global/util/PasswordUtil.java | 46 ++++++ .../stempo/api/global/util/ResponseUtil.java | 15 ++ .../com/stempo/api/ApiApplicationTests.java | 13 -- 35 files changed, 949 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/stempo/api/domain/application/service/LoginService.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/LoginServiceImpl.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/PasswordService.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/PasswordServiceImpl.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/RedisTokenService.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/RedisTokenServiceImpl.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/UserService.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java create mode 100644 src/main/java/com/stempo/api/domain/domain/model/RedisToken.java create mode 100644 src/main/java/com/stempo/api/domain/domain/model/Role.java create mode 100644 src/main/java/com/stempo/api/domain/domain/model/User.java create mode 100644 src/main/java/com/stempo/api/domain/domain/repository/RedisTokenRepository.java create mode 100644 src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/mappper/UserMapper.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenJpaRepository.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenRepositoryImpl.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/repository/UserJpaRepository.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/LoginController.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/dto/response/TokenInfo.java create mode 100644 src/main/java/com/stempo/api/global/auth/exception/TokenForgeryException.java create mode 100644 src/main/java/com/stempo/api/global/auth/exception/TokenNotFoundException.java create mode 100644 src/main/java/com/stempo/api/global/auth/exception/TokenValidateException.java create mode 100644 src/main/java/com/stempo/api/global/auth/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/stempo/api/global/auth/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/stempo/api/global/common/dto/ApiResponse.java create mode 100644 src/main/java/com/stempo/api/global/config/OpenApiConfig.java create mode 100644 src/main/java/com/stempo/api/global/config/PasswordEncoderConfig.java create mode 100644 src/main/java/com/stempo/api/global/config/SecurityConfig.java create mode 100644 src/main/java/com/stempo/api/global/exception/NotFoundException.java create mode 100644 src/main/java/com/stempo/api/global/util/PasswordUtil.java create mode 100644 src/main/java/com/stempo/api/global/util/ResponseUtil.java delete mode 100644 src/test/java/com/stempo/api/ApiApplicationTests.java diff --git a/build.gradle b/build.gradle index ed2acb28..16a927f1 100644 --- a/build.gradle +++ b/build.gradle @@ -25,12 +25,13 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // JWT 라이브러리 - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' // JWT 구현체 - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JWT Jackson 모듈 + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' // JWT 라이브러리 + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' // JWT 구현체 + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' // JWT Jackson 모듈 // DB implementation 'org.mariadb.jdbc:mariadb-java-client:3.4.1' // MariaDB JDBC Driver + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Redis implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-validation' // Hibernate Validator implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final' // Bean Validation diff --git a/src/main/java/com/stempo/api/domain/application/service/LoginService.java b/src/main/java/com/stempo/api/domain/application/service/LoginService.java new file mode 100644 index 00000000..d4a4b512 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/LoginService.java @@ -0,0 +1,11 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.presentation.dto.response.TokenInfo; +import jakarta.servlet.http.HttpServletRequest; + +public interface LoginService { + + TokenInfo loginOrRegister(String deviceTag, String password); + + TokenInfo reissueToken(HttpServletRequest request); +} diff --git a/src/main/java/com/stempo/api/domain/application/service/LoginServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/LoginServiceImpl.java new file mode 100644 index 00000000..e7410b41 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/LoginServiceImpl.java @@ -0,0 +1,53 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.RedisToken; +import com.stempo.api.domain.domain.model.User; +import com.stempo.api.domain.presentation.dto.response.TokenInfo; +import com.stempo.api.global.auth.exception.TokenForgeryException; +import com.stempo.api.global.auth.jwt.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LoginServiceImpl implements LoginService { + + private final UserService userService; + private final RedisTokenService redisTokenService; + private final JwtTokenProvider jwtTokenProvider; + + @Override + public TokenInfo loginOrRegister(String deviceTag, String password) { + User user = userService.findById(deviceTag) + .orElseGet(() -> userService.registerUser(deviceTag, password)); + return generateAndSaveToken(user); + } + + @Override + public TokenInfo reissueToken(HttpServletRequest request) { + String refreshToken = jwtTokenProvider.resolveToken(request); + Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken); + RedisToken redisToken = redisTokenService.findByRefreshToken(refreshToken); + + validateUserExistence(authentication); + + TokenInfo newTokenInfo = jwtTokenProvider.generateToken(redisToken.getId(), redisToken.getRole()); + redisTokenService.saveToken(redisToken.getId(), redisToken.getRole(), newTokenInfo); + return newTokenInfo; + } + + private TokenInfo generateAndSaveToken(User loginUser) { + TokenInfo tokenInfo = jwtTokenProvider.generateToken(loginUser.getDeviceTag(), loginUser.getRole()); + redisTokenService.saveToken(loginUser.getDeviceTag(), loginUser.getRole(), tokenInfo); + return tokenInfo; + } + + private void validateUserExistence(Authentication authentication) { + String id = authentication.getName(); + if (!userService.existsById(id)) { + throw new TokenForgeryException("Non-existent user token."); + } + } +} diff --git a/src/main/java/com/stempo/api/domain/application/service/PasswordService.java b/src/main/java/com/stempo/api/domain/application/service/PasswordService.java new file mode 100644 index 00000000..8aa0f32c --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/PasswordService.java @@ -0,0 +1,5 @@ +package com.stempo.api.domain.application.service; + +public interface PasswordService { + String encodePassword(String password); +} diff --git a/src/main/java/com/stempo/api/domain/application/service/PasswordServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/PasswordServiceImpl.java new file mode 100644 index 00000000..4d159636 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/PasswordServiceImpl.java @@ -0,0 +1,17 @@ +package com.stempo.api.domain.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PasswordServiceImpl implements PasswordService { + + private final PasswordEncoder passwordEncoder; + + @Override + public String encodePassword(String rawPassword) { + return passwordEncoder.encode(rawPassword); + } +} diff --git a/src/main/java/com/stempo/api/domain/application/service/RedisTokenService.java b/src/main/java/com/stempo/api/domain/application/service/RedisTokenService.java new file mode 100644 index 00000000..4f4bc045 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/RedisTokenService.java @@ -0,0 +1,14 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.RedisToken; +import com.stempo.api.domain.domain.model.Role; +import com.stempo.api.domain.presentation.dto.response.TokenInfo; + +public interface RedisTokenService { + + RedisToken findByAccessToken(String token); + + RedisToken findByRefreshToken(String token); + + void saveToken(String id, Role role, TokenInfo tokenInfo); +} diff --git a/src/main/java/com/stempo/api/domain/application/service/RedisTokenServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/RedisTokenServiceImpl.java new file mode 100644 index 00000000..a327f1d8 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/RedisTokenServiceImpl.java @@ -0,0 +1,34 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.RedisToken; +import com.stempo.api.domain.domain.model.Role; +import com.stempo.api.domain.domain.repository.RedisTokenRepository; +import com.stempo.api.domain.presentation.dto.response.TokenInfo; +import com.stempo.api.global.auth.exception.TokenNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RedisTokenServiceImpl implements RedisTokenService { + + private final RedisTokenRepository redisTokenRepository; + + @Override + public RedisToken findByAccessToken(String token) { + return redisTokenRepository.findByAccessToken(token) + .orElseThrow(() -> new TokenNotFoundException("존재하지 않는 토큰입니다.")); + } + + @Override + public RedisToken findByRefreshToken(String token) { + return redisTokenRepository.findByRefreshToken(token) + .orElseThrow(() -> new TokenNotFoundException("존재하지 않는 토큰입니다.")); + } + + @Override + public void saveToken(String id, Role role, TokenInfo tokenInfo) { + RedisToken redisToken = RedisToken.create(id, role, tokenInfo); + redisTokenRepository.save(redisToken); + } +} diff --git a/src/main/java/com/stempo/api/domain/application/service/UserService.java b/src/main/java/com/stempo/api/domain/application/service/UserService.java new file mode 100644 index 00000000..2db2e857 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/UserService.java @@ -0,0 +1,14 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.User; + +import java.util.Optional; + +public interface UserService { + + User registerUser(String deviceTag, String password); + + Optional findById(String id); + + boolean existsById(String id); +} diff --git a/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java new file mode 100644 index 00000000..75735497 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java @@ -0,0 +1,37 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.User; +import com.stempo.api.domain.domain.repository.UserRepository; +import com.stempo.api.global.util.PasswordUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordService passwordService; + private final PasswordUtil passwordUtil; + + @Override + public User registerUser(String deviceTag, String password) { + String rawPassword = password != null ? password : passwordUtil.generateStrongPassword(); + User user = User.create(deviceTag, rawPassword); + String encodedPassword = passwordService.encodePassword(user.getPassword()); + user.updatePassword(encodedPassword); + return userRepository.save(user); + } + + @Override + public Optional findById(String id) { + return userRepository.findById(id); + } + + @Override + public boolean existsById(String id) { + return userRepository.existsById(id); + } +} diff --git a/src/main/java/com/stempo/api/domain/domain/model/RedisToken.java b/src/main/java/com/stempo/api/domain/domain/model/RedisToken.java new file mode 100644 index 00000000..02a0ac07 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/model/RedisToken.java @@ -0,0 +1,43 @@ +package com.stempo.api.domain.domain.model; + +import com.stempo.api.domain.presentation.dto.response.TokenInfo; +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RedisHash(value = "refresh", timeToLive = 60 * 60 * 24 * 14) +public class RedisToken { + + @Id + @Column(name = "user_id") + private String id; + + private Role role; + + @Indexed + private String accessToken; + + @Indexed + private String refreshToken; + + public static RedisToken create(String id, Role role, TokenInfo tokenInfo) { + return RedisToken.builder() + .id(id) + .role(role) + .accessToken(tokenInfo.getAccessToken()) + .refreshToken(tokenInfo.getRefreshToken()) + .build(); + } +} diff --git a/src/main/java/com/stempo/api/domain/domain/model/Role.java b/src/main/java/com/stempo/api/domain/domain/model/Role.java new file mode 100644 index 00000000..a907be01 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/model/Role.java @@ -0,0 +1,15 @@ +package com.stempo.api.domain.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Role { + + USER("ROLE_USER", "Normal User"), + ADMIN("ROLE_ADMIN", "Administrator"); + + private final String key; + private final String description; +} diff --git a/src/main/java/com/stempo/api/domain/domain/model/User.java b/src/main/java/com/stempo/api/domain/domain/model/User.java new file mode 100644 index 00000000..b728d04f --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/model/User.java @@ -0,0 +1,28 @@ +package com.stempo.api.domain.domain.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class User { + + private String deviceTag; + private String password; + private Role role; + + public static User create(String deviceTag, String password) { + return new User(deviceTag, password, Role.USER); + } + + public void updatePassword(String encodedPassword) { + setPassword(encodedPassword); + } +} diff --git a/src/main/java/com/stempo/api/domain/domain/repository/RedisTokenRepository.java b/src/main/java/com/stempo/api/domain/domain/repository/RedisTokenRepository.java new file mode 100644 index 00000000..f3442e68 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/repository/RedisTokenRepository.java @@ -0,0 +1,14 @@ +package com.stempo.api.domain.domain.repository; + +import com.stempo.api.domain.domain.model.RedisToken; + +import java.util.Optional; + +public interface RedisTokenRepository { + + Optional findByAccessToken(String token); + + Optional findByRefreshToken(String token); + + void save(RedisToken redisToken); +} diff --git a/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java b/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java new file mode 100644 index 00000000..e43821a4 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.stempo.api.domain.domain.repository; + +import com.stempo.api.domain.domain.model.User; + +import java.util.Optional; + +public interface UserRepository { + + User save(User user); + + Optional findById(String id); + + boolean existsById(String id); +} diff --git a/src/main/java/com/stempo/api/domain/persistence/entity/UserEntity.java b/src/main/java/com/stempo/api/domain/persistence/entity/UserEntity.java index ce3e79b1..e9d7dbf2 100644 --- a/src/main/java/com/stempo/api/domain/persistence/entity/UserEntity.java +++ b/src/main/java/com/stempo/api/domain/persistence/entity/UserEntity.java @@ -1,7 +1,10 @@ package com.stempo.api.domain.persistence.entity; +import com.stempo.api.domain.domain.model.Role; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -21,8 +24,11 @@ public class UserEntity extends BaseEntity { @Id - private String id; + private String deviceTag; @Column(nullable = false) private String password; + + @Enumerated(EnumType.STRING) + private Role role; } diff --git a/src/main/java/com/stempo/api/domain/persistence/mappper/UserMapper.java b/src/main/java/com/stempo/api/domain/persistence/mappper/UserMapper.java new file mode 100644 index 00000000..89542444 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/mappper/UserMapper.java @@ -0,0 +1,23 @@ +package com.stempo.api.domain.persistence.mappper; + +import com.stempo.api.domain.domain.model.User; +import com.stempo.api.domain.persistence.entity.UserEntity; + +public class UserMapper { + + public static UserEntity toEntity(User user) { + return UserEntity.builder() + .deviceTag(user.getDeviceTag()) + .password(user.getPassword()) + .role(user.getRole()) + .build(); + } + + public static User toDomain(UserEntity entity) { + return User.builder() + .deviceTag(entity.getDeviceTag()) + .password(entity.getPassword()) + .role(entity.getRole()) + .build(); + } +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenJpaRepository.java b/src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenJpaRepository.java new file mode 100644 index 00000000..aeb8b02a --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenJpaRepository.java @@ -0,0 +1,13 @@ +package com.stempo.api.domain.persistence.repository; + +import com.stempo.api.domain.domain.model.RedisToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RedisTokenJpaRepository extends CrudRepository { + + Optional findByAccessToken(String token); + + Optional findByRefreshToken(String token); +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenRepositoryImpl.java b/src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenRepositoryImpl.java new file mode 100644 index 00000000..9815ce39 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/repository/RedisTokenRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.stempo.api.domain.persistence.repository; + +import com.stempo.api.domain.domain.model.RedisToken; +import com.stempo.api.domain.domain.repository.RedisTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class RedisTokenRepositoryImpl implements RedisTokenRepository { + + private final RedisTokenJpaRepository repository; + + @Override + public Optional findByAccessToken(String token) { + return repository.findByAccessToken(token); + } + + @Override + public Optional findByRefreshToken(String token) { + return repository.findByRefreshToken(token); + } + + @Override + public void save(RedisToken redisToken) { + repository.save(redisToken); + } +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/UserJpaRepository.java b/src/main/java/com/stempo/api/domain/persistence/repository/UserJpaRepository.java new file mode 100644 index 00000000..8968b550 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/repository/UserJpaRepository.java @@ -0,0 +1,7 @@ +package com.stempo.api.domain.persistence.repository; + +import com.stempo.api.domain.persistence.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java b/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java new file mode 100644 index 00000000..9d2cd9c6 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.stempo.api.domain.persistence.repository; + +import com.stempo.api.domain.domain.model.User; +import com.stempo.api.domain.domain.repository.UserRepository; +import com.stempo.api.domain.persistence.entity.UserEntity; +import com.stempo.api.domain.persistence.mappper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository repository; + + @Override + public User save(User user) { + UserEntity entity = repository.save(UserMapper.toEntity(user)); + return UserMapper.toDomain(entity); + } + + @Override + public Optional findById(String id) { + return repository.findById(id) + .map(UserMapper::toDomain); + } + + @Override + public boolean existsById(String id) { + return repository.existsById(id); + } +} diff --git a/src/main/java/com/stempo/api/domain/presentation/LoginController.java b/src/main/java/com/stempo/api/domain/presentation/LoginController.java new file mode 100644 index 00000000..7a6070bb --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/LoginController.java @@ -0,0 +1,47 @@ +package com.stempo.api.domain.presentation; + +import com.stempo.api.domain.application.service.LoginService; +import com.stempo.api.domain.presentation.dto.response.TokenInfo; +import com.stempo.api.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Tag(name = "Login", description = "로그인") +public class LoginController { + + private final LoginService loginService; + + @Operation(summary = "로그인", description = "ROLE_ANONYMOUS 이상의권한이 필요함
" + + "일반 계정일 경우 Device-Tag만 기입하면 됨") + @PostMapping("/api/vi/login") + public ApiResponse login( + @RequestHeader(value = "Device-Tag", defaultValue = "490154203237518") String deviceTag, + @RequestHeader(value = "Password", required = false) String password, + HttpServletResponse response + ) { + TokenInfo token = loginService.loginOrRegister(deviceTag, password); + response.setHeader("Authorization", "Bearer " + token.getAccessToken()); + response.setHeader("Refresh-Token", token.getRefreshToken()); + return ApiResponse.success(); + } + + @Operation(summary = "[U] 토큰 재발급", description = "ROLE_USER 이상의 권한이 필요함") + @PostMapping("/api/vi/reissue") + public ApiResponse reissueToken( + HttpServletRequest request, + HttpServletResponse response + ) { + TokenInfo token = loginService.reissueToken(request); + response.setHeader("Authorization", "Bearer " + token.getAccessToken()); + response.setHeader("Refresh-Token", token.getRefreshToken()); + return ApiResponse.success(); + } +} diff --git a/src/main/java/com/stempo/api/domain/presentation/dto/response/TokenInfo.java b/src/main/java/com/stempo/api/domain/presentation/dto/response/TokenInfo.java new file mode 100644 index 00000000..a0d3f4b3 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/dto/response/TokenInfo.java @@ -0,0 +1,22 @@ +package com.stempo.api.domain.presentation.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class TokenInfo { + + private String accessToken; + private String refreshToken; + + public static TokenInfo create(String accessToken, String refreshToken) { + return TokenInfo.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/stempo/api/global/auth/exception/TokenForgeryException.java b/src/main/java/com/stempo/api/global/auth/exception/TokenForgeryException.java new file mode 100644 index 00000000..e78239c4 --- /dev/null +++ b/src/main/java/com/stempo/api/global/auth/exception/TokenForgeryException.java @@ -0,0 +1,15 @@ +package com.stempo.api.global.auth.exception; + +public class TokenForgeryException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "토큰이 변조되었습니다."; + + public TokenForgeryException() { + super(DEFAULT_MESSAGE); + } + + public TokenForgeryException(String s) { + super(s); + } + +} diff --git a/src/main/java/com/stempo/api/global/auth/exception/TokenNotFoundException.java b/src/main/java/com/stempo/api/global/auth/exception/TokenNotFoundException.java new file mode 100644 index 00000000..ec8043b4 --- /dev/null +++ b/src/main/java/com/stempo/api/global/auth/exception/TokenNotFoundException.java @@ -0,0 +1,15 @@ +package com.stempo.api.global.auth.exception; + +public class TokenNotFoundException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "토큰이 존재하지 않습니다."; + + public TokenNotFoundException() { + super(DEFAULT_MESSAGE); + } + + public TokenNotFoundException(String s) { + super(s); + } + +} diff --git a/src/main/java/com/stempo/api/global/auth/exception/TokenValidateException.java b/src/main/java/com/stempo/api/global/auth/exception/TokenValidateException.java new file mode 100644 index 00000000..24a3d0cb --- /dev/null +++ b/src/main/java/com/stempo/api/global/auth/exception/TokenValidateException.java @@ -0,0 +1,15 @@ +package com.stempo.api.global.auth.exception; + +public class TokenValidateException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "토큰이 유효하지 않습니다."; + + public TokenValidateException() { + super(DEFAULT_MESSAGE); + } + + public TokenValidateException(String s) { + super(s); + } + +} diff --git a/src/main/java/com/stempo/api/global/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/stempo/api/global/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..345b19df --- /dev/null +++ b/src/main/java/com/stempo/api/global/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package com.stempo.api.global.auth.filter; + +import com.stempo.api.domain.application.service.RedisTokenService; +import com.stempo.api.domain.domain.model.RedisToken; +import com.stempo.api.global.auth.jwt.JwtTokenProvider; +import com.stempo.api.global.util.ResponseUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends GenericFilterBean { + + private final RedisTokenService redisTokenService; + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) request; + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + if (!authenticateToken(httpServletRequest, httpServletResponse)) { + return; + } + chain.doFilter(request, response); + } + + private boolean authenticateToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + String token = jwtTokenProvider.resolveToken(request); + if (token != null && jwtTokenProvider.validateToken(token)) { + RedisToken redisToken = jwtTokenProvider.isRefreshToken(token) ? redisTokenService.findByRefreshToken(token) : redisTokenService.findByAccessToken(token); + if (redisToken == null) { + log.warn("Token not found in redis"); + ResponseUtil.sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + return true; + } +} diff --git a/src/main/java/com/stempo/api/global/auth/jwt/JwtTokenProvider.java b/src/main/java/com/stempo/api/global/auth/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..4a3af905 --- /dev/null +++ b/src/main/java/com/stempo/api/global/auth/jwt/JwtTokenProvider.java @@ -0,0 +1,150 @@ +package com.stempo.api.global.auth.jwt; + +import com.stempo.api.domain.domain.model.Role; +import com.stempo.api.domain.presentation.dto.response.TokenInfo; +import com.stempo.api.global.auth.exception.TokenValidateException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; + +@Component +@Slf4j +public class JwtTokenProvider { + + private final Key key; + private final long accessTokenDuration; + private final long refreshTokenDuration; + + public JwtTokenProvider( + @Value("${security.jwt.secret-key}") String secretKey, + @Value("${security.jwt.token-validity-in-seconds.access-token}") long accessTokenDuration, + @Value("${security.jwt.token-validity-in-seconds.refresh-token}") long refreshTokenDuration + ) { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + this.accessTokenDuration = accessTokenDuration; + this.refreshTokenDuration = refreshTokenDuration; + } + + public TokenInfo generateToken(String id, Role role) { + Date expiry = new Date(); + Date accessTokenExpiry = new Date(expiry.getTime() + (accessTokenDuration)); + String accessToken = Jwts.builder() + .setSubject(id) + .claim("role", role) + .setIssuedAt(expiry) + .setExpiration(accessTokenExpiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + Date refreshTokenExpiry = new Date(expiry.getTime() + (refreshTokenDuration)); + String refreshToken = Jwts.builder() + .setSubject(id) + .claim("role", role) + .setIssuedAt(expiry) + .setExpiration(refreshTokenExpiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return TokenInfo.create(accessToken, refreshToken); + } + + public boolean isRefreshToken(String token) { + try { + Claims claims = parseClaims(token); + Date issuedAt = claims.getIssuedAt(); + Date expiration = claims.getExpiration(); + if (issuedAt != null && expiration != null) { + long duration = expiration.getTime() - issuedAt.getTime(); + return duration == refreshTokenDuration; + } + } catch (Exception e) { + log.debug("Failed to check if the token is a refresh token", e); + } + return false; + } + + public Authentication getAuthentication(String accessToken) { + Claims claims = parseClaims(accessToken); + log.debug("claims : {}", claims); + log.debug("accessToken : {}", accessToken); + + if (claims.get("role") == null) { + throw new TokenValidateException("권한 정보가 없는 토큰입니다."); + } + + Collection authorities = + Arrays.stream(claims.get("role").toString().split(",")) + .map(this::formatRoleString) + .map(SimpleGrantedAuthority::new) + .toList(); + + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7); + } + return null; + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token"); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token"); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token"); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty."); + } + return false; + } + + public Claims parseClaims(String accessToken) { + try { + return Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + private String formatRoleString(String role) { + if (!role.startsWith("ROLE_")) { + return "ROLE_" + role; + } + return role; + } +} diff --git a/src/main/java/com/stempo/api/global/common/dto/ApiResponse.java b/src/main/java/com/stempo/api/global/common/dto/ApiResponse.java new file mode 100644 index 00000000..4c18ef41 --- /dev/null +++ b/src/main/java/com/stempo/api/global/common/dto/ApiResponse.java @@ -0,0 +1,43 @@ +package com.stempo.api.global.common.dto; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ApiResponse { + + @Builder.Default + private Boolean success = true; + private T data; + + public static ApiResponse success() { + return ApiResponse. builder().build(); + } + + public static ApiResponse success(T data) { + return ApiResponse. builder() + .data(data) + .build(); + } + + public static ApiResponse failure() { + return ApiResponse. builder() + .success(false) + .build(); + } + + public static ApiResponse failure(T data) { + return ApiResponse. builder() + .success(false) + .data(data) + .build(); + } + + public String toJson() { + Gson gson = new GsonBuilder().serializeNulls().create(); + return gson.toJson(this); + } +} diff --git a/src/main/java/com/stempo/api/global/config/OpenApiConfig.java b/src/main/java/com/stempo/api/global/config/OpenApiConfig.java new file mode 100644 index 00000000..f20f1e87 --- /dev/null +++ b/src/main/java/com/stempo/api/global/config/OpenApiConfig.java @@ -0,0 +1,49 @@ +package com.stempo.api.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI(@Value("${springdoc.version}") String appVersion) { + Info info = new Info().title("Stempo").version(appVersion) + .description("Stempo API Document") + .termsOfService("http://swagger.io/terms/") + .contact(new Contact().name("한관희").url("https://github.com/limehee").email("noop103@naver.com")) + .license(new License().name("Stempo License Version 1.0").url("https://github.com/KKKK-Stempo")); + + final String securitySchemeName = "bearerAuth"; + Server server = new Server().url("/"); + + return new OpenAPI() + .servers(List.of(server)) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components( + new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + ) + ) + .info(info); + } +} diff --git a/src/main/java/com/stempo/api/global/config/PasswordEncoderConfig.java b/src/main/java/com/stempo/api/global/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..140a2a0e --- /dev/null +++ b/src/main/java/com/stempo/api/global/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.stempo.api.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/stempo/api/global/config/SecurityConfig.java b/src/main/java/com/stempo/api/global/config/SecurityConfig.java new file mode 100644 index 00000000..866291d2 --- /dev/null +++ b/src/main/java/com/stempo/api/global/config/SecurityConfig.java @@ -0,0 +1,40 @@ +package com.stempo.api.global.config; + +import com.stempo.api.domain.application.service.RedisTokenService; +import com.stempo.api.global.auth.filter.JwtAuthenticationFilter; +import com.stempo.api.global.auth.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final RedisTokenService redisTokenService; + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(authorizeRequests -> + authorizeRequests.anyRequest().permitAll() + ) + .addFilterBefore( + new JwtAuthenticationFilter(redisTokenService, jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class + ); + return http.build(); + } +} diff --git a/src/main/java/com/stempo/api/global/exception/NotFoundException.java b/src/main/java/com/stempo/api/global/exception/NotFoundException.java new file mode 100644 index 00000000..b89d3ab8 --- /dev/null +++ b/src/main/java/com/stempo/api/global/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package com.stempo.api.global.exception; + +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/stempo/api/global/util/PasswordUtil.java b/src/main/java/com/stempo/api/global/util/PasswordUtil.java new file mode 100644 index 00000000..d2c86768 --- /dev/null +++ b/src/main/java/com/stempo/api/global/util/PasswordUtil.java @@ -0,0 +1,46 @@ +package com.stempo.api.global.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Component +public class PasswordUtil { + private static final String UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String LOWER = "abcdefghijklmnopqrstuvwxyz"; + private static final String DIGITS = "0123456789"; + private static final String SPECIAL = "!@#$%^&*()_+-=[]{}|;:,.<>?"; + private static final String ALL = UPPER + LOWER + DIGITS + SPECIAL; + private static final SecureRandom RANDOM = new SecureRandom(); + + @Value("${security.account.password-length}") + private int passwordLength; + + public String generateStrongPassword() { + List passwordChars = new ArrayList<>(); + + // 적어도 하나의 대문자, 소문자, 숫자, 특수문자를 포함 + passwordChars.add(UPPER.charAt(RANDOM.nextInt(UPPER.length()))); + passwordChars.add(LOWER.charAt(RANDOM.nextInt(LOWER.length()))); + passwordChars.add(DIGITS.charAt(RANDOM.nextInt(DIGITS.length()))); + passwordChars.add(SPECIAL.charAt(RANDOM.nextInt(SPECIAL.length()))); + + // 나머지 길이만큼 임의의 문자로 채움 + for (int i = 4; i < passwordLength; i++) { + passwordChars.add(ALL.charAt(RANDOM.nextInt(ALL.length()))); + } + + Collections.shuffle(passwordChars, RANDOM); + + StringBuilder password = new StringBuilder(passwordLength); + for (char c : passwordChars) { + password.append(c); + } + + return password.toString(); + } +} diff --git a/src/main/java/com/stempo/api/global/util/ResponseUtil.java b/src/main/java/com/stempo/api/global/util/ResponseUtil.java new file mode 100644 index 00000000..58cb6de4 --- /dev/null +++ b/src/main/java/com/stempo/api/global/util/ResponseUtil.java @@ -0,0 +1,15 @@ +package com.stempo.api.global.util; + +import com.stempo.api.global.common.dto.ApiResponse; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class ResponseUtil { + + public static void sendErrorResponse(HttpServletResponse response, int status) throws IOException { + response.getWriter().write(ApiResponse.failure().toJson()); + response.setContentType("application/json"); + response.setStatus(status); + } +} diff --git a/src/test/java/com/stempo/api/ApiApplicationTests.java b/src/test/java/com/stempo/api/ApiApplicationTests.java deleted file mode 100644 index 21ebae23..00000000 --- a/src/test/java/com/stempo/api/ApiApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.stempo.api; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ApiApplicationTests { - - @Test - void contextLoads() { - } - -}