From e1165a02d90088ce95f31c9b3d5a2f18c84b3c15 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:21:34 +0900 Subject: [PATCH 01/15] =?UTF-8?q?SECURITY:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B2=98=EB=A6=AC=20(?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EB=B0=8F=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EC=84=A4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminAuthController.java | 95 +++++++++++++++ .../admin/dto/request/LoginRequestDto.java | 10 ++ .../dev/admin/admin/dto/response/JwtDto.java | 12 ++ .../admin/admin/dto/response/JwtPayload.java | 17 +++ .../admin/admin/security/AdminDetails.java | 80 +++++++++++++ .../admin/security/AdminDetailsService.java | 24 ++++ .../command/AdminLoginCommandService.java | 46 ++++++++ .../java/dev/admin/admin/utils/JwtUtil.java | 109 ++++++++++++++++++ 8 files changed, 393 insertions(+) create mode 100644 src/main/java/dev/admin/admin/controller/AdminAuthController.java create mode 100644 src/main/java/dev/admin/admin/dto/request/LoginRequestDto.java create mode 100644 src/main/java/dev/admin/admin/dto/response/JwtDto.java create mode 100644 src/main/java/dev/admin/admin/dto/response/JwtPayload.java create mode 100644 src/main/java/dev/admin/admin/security/AdminDetails.java create mode 100644 src/main/java/dev/admin/admin/security/AdminDetailsService.java create mode 100644 src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java create mode 100644 src/main/java/dev/admin/admin/utils/JwtUtil.java diff --git a/src/main/java/dev/admin/admin/controller/AdminAuthController.java b/src/main/java/dev/admin/admin/controller/AdminAuthController.java new file mode 100644 index 0000000..a15242b --- /dev/null +++ b/src/main/java/dev/admin/admin/controller/AdminAuthController.java @@ -0,0 +1,95 @@ +package dev.admin.admin.controller; + +import dev.admin.admin.dto.request.LoginRequestDto; +import dev.admin.admin.dto.response.AdminInfoResponse; +import dev.admin.admin.dto.response.JwtDto; +import dev.admin.admin.dto.response.JwtPayload; +import dev.admin.admin.service.command.AdminLoginCommandService; +import dev.admin.admin.service.command.AdminLogoutCommandService; +import dev.admin.admin.utils.JwtUtil; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AdminLoginCommandService adminLoginCommandService; + private final AdminLogoutCommandService adminLogoutCommandService; + private final JwtUtil jwtUtil; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequestDto requestDto, HttpServletResponse response) { + JwtDto tokenDto = adminLoginCommandService.login(requestDto); + + // 쿠키 설정은 그대로 유지 + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", tokenDto.getAccessToken()) + .httpOnly(true) + .secure(false) + .path("/") + .sameSite("Lax") + .maxAge(60 * 60) + .build(); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", tokenDto.getRefreshToken()) + .httpOnly(true) + .secure(false) + .path("/") + .sameSite("Lax") + .maxAge(7 * 24 * 60 * 60) + .build(); + + response.addHeader("Set-Cookie", accessTokenCookie.toString()); + response.addHeader("Set-Cookie", refreshTokenCookie.toString()); + + // ✅ 응답 바디에 토큰도 포함 + return ResponseEntity.ok(tokenDto); + } + + @PostMapping("/logout") + public ResponseEntity logout(@CookieValue("refreshToken") String refreshToken, + HttpServletResponse response) { + // 서비스 호출: refreshToken 기반 로그아웃 처리 (DB/Redis 삭제 등) + adminLogoutCommandService.logout(refreshToken); + + // accessToken & refreshToken 쿠키 삭제 + ResponseCookie clearAccessToken = ResponseCookie.from("accessToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + + ResponseCookie clearRefreshToken = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + + response.addHeader("Set-Cookie", clearAccessToken.toString()); + response.addHeader("Set-Cookie", clearRefreshToken.toString()); + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/me") + public ResponseEntity me(@CookieValue("accessToken") String accessToken) { + JwtPayload payload = jwtUtil.parseToken(accessToken); + return ResponseEntity.ok( + new AdminInfoResponse( + payload.getSub(), + payload.getEmail(), + payload.getName(), + payload.getRole() + ) + ); + } + +} diff --git a/src/main/java/dev/admin/admin/dto/request/LoginRequestDto.java b/src/main/java/dev/admin/admin/dto/request/LoginRequestDto.java new file mode 100644 index 0000000..ed1d95c --- /dev/null +++ b/src/main/java/dev/admin/admin/dto/request/LoginRequestDto.java @@ -0,0 +1,10 @@ +package dev.admin.admin.dto.request; + +import lombok.Getter; + +@Getter +public class LoginRequestDto { + + private String email; + private String password; +} diff --git a/src/main/java/dev/admin/admin/dto/response/JwtDto.java b/src/main/java/dev/admin/admin/dto/response/JwtDto.java new file mode 100644 index 0000000..abb72dd --- /dev/null +++ b/src/main/java/dev/admin/admin/dto/response/JwtDto.java @@ -0,0 +1,12 @@ +package dev.admin.admin.dto.response; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JwtDto { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/dev/admin/admin/dto/response/JwtPayload.java b/src/main/java/dev/admin/admin/dto/response/JwtPayload.java new file mode 100644 index 0000000..98c0f92 --- /dev/null +++ b/src/main/java/dev/admin/admin/dto/response/JwtPayload.java @@ -0,0 +1,17 @@ +package dev.admin.admin.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class JwtPayload { + private String sub; // subject (adminId) + private String email; + private String name; + private String role; +} diff --git a/src/main/java/dev/admin/admin/security/AdminDetails.java b/src/main/java/dev/admin/admin/security/AdminDetails.java new file mode 100644 index 0000000..e0132d1 --- /dev/null +++ b/src/main/java/dev/admin/admin/security/AdminDetails.java @@ -0,0 +1,80 @@ +package dev.admin.admin.security; + +import java.util.List; +import dev.admin.admin.entity.Admin; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +@RequiredArgsConstructor +public class AdminDetails implements UserDetails { + + // 실제 DB에서 조회된 Admin 객체를 담음 + private final Admin admin; + + /** + * 관리자 권한 반환 + */ + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + + /** + * 로그인에 사용할 비밀번호 반환 + */ + @Override + public String getPassword() { + return admin.getPassword(); + } + + /** + * 로그인에 사용할 ID(email) 반환 + */ + @Override + public String getUsername() { + return admin.getEmail(); + } + + /** + * 계정 만료 여부 (true = 만료되지 않음) + */ + @Override + public boolean isAccountNonExpired() { + return true; + } + + /** + * 계정 잠김 여부 (true = 잠기지 않음) + */ + @Override + public boolean isAccountNonLocked() { + return true; + } + + /** + * 자격 증명(비밀번호) 만료 여부 (true = 만료되지 않음) + */ + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + /** + * 계정 활성화 여부 (true = 활성 계정) + */ + @Override + public boolean isEnabled() { + return true; + } + + /** + * 인증이 완료된 관리자의 엔티티 객체를 외부에서 사용할 수 있도록 반환 + */ + public Admin getAdmin() { + return this.admin; + } +} diff --git a/src/main/java/dev/admin/admin/security/AdminDetailsService.java b/src/main/java/dev/admin/admin/security/AdminDetailsService.java new file mode 100644 index 0000000..cddcce6 --- /dev/null +++ b/src/main/java/dev/admin/admin/security/AdminDetailsService.java @@ -0,0 +1,24 @@ +package dev.admin.admin.security; + +import dev.admin.admin.entity.Admin; +import dev.admin.admin.repository.AdminRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminDetailsService implements UserDetailsService { + + private final AdminRepository adminRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Admin admin = adminRepository.findByEmail(email.trim()) + .orElseThrow(() -> new UsernameNotFoundException("관리자를 찾을 수 없습니다.")); + return new AdminDetails(admin); + } + +} diff --git a/src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java b/src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java new file mode 100644 index 0000000..9e5daae --- /dev/null +++ b/src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java @@ -0,0 +1,46 @@ +package dev.admin.admin.service.command; + +import dev.admin.admin.dto.request.LoginRequestDto; +import dev.admin.admin.dto.response.JwtDto; +import dev.admin.admin.entity.Admin; +import dev.admin.admin.repository.AdminRepository; +import dev.admin.admin.repository.TokenRepository; +import dev.admin.admin.utils.JwtUtil; +import dev.admin.global.apiPayload.code.status.ErrorStatus; +import dev.admin.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminLoginCommandService { + + private final AdminRepository adminRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final TokenRepository tokenRepository; + + public JwtDto login(LoginRequestDto requestDto) { + Admin admin = adminRepository.findByEmail(requestDto.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 관리자입니다.")); + + if (!passwordEncoder.matches(requestDto.getPassword(), admin.getPassword())) { + throw new GeneralException(ErrorStatus.INVALID_PASSWORD); + } + + String accessToken = jwtUtil.generateAccessToken( + admin.getId().toString(), + admin.getEmail(), + admin.getName(), + admin.getRole().name() + ); + + String refreshToken = jwtUtil.generateRefreshToken(); + tokenRepository.save(admin.getEmail(), refreshToken); + + return new JwtDto(accessToken, refreshToken); + } + + +} diff --git a/src/main/java/dev/admin/admin/utils/JwtUtil.java b/src/main/java/dev/admin/admin/utils/JwtUtil.java new file mode 100644 index 0000000..7fcd4ec --- /dev/null +++ b/src/main/java/dev/admin/admin/utils/JwtUtil.java @@ -0,0 +1,109 @@ +package dev.admin.admin.utils; + +import dev.admin.admin.dto.response.JwtPayload; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Component +public class JwtUtil { + + @Value("${spring.jwt.secret}") + private String secretKey; + + @Value("${spring.jwt.token.access-expiration-time}") + private long accessTokenExpiration; + + @Value("${spring.jwt.token.refresh-expiration-time}") + private long refreshTokenExpiration; + + private Key key; + + /** + * Bean 생성 직후 실행되어, 문자열 시크릿 키를 HMAC 키 객체로 변환 + */ + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + /** + * Access Token 생성 + */ + public String generateAccessToken(String id, String email, String name, String role) { + return Jwts.builder() + .setSubject(id) // sub = 사용자 id + .claim("email", email) + .claim("name", name) + .claim("role", role) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + + /** + * Refresh Token 생성 + */ + public String generateRefreshToken() { + return Jwts.builder() + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiration)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 토큰에서 사용자 이메일(subject) 추출 + */ + public String getEmailFromToken(String token) { + return parseClaims(token).get("email", String.class); + } + + /** + * 토큰 유효성 검증 + */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + System.out.println("토큰 만료"); + } catch (UnsupportedJwtException e) { + System.out.println("지원되지 않는 토큰"); + } catch (MalformedJwtException e) { + System.out.println("잘못된 형식의 토큰"); + } catch (SecurityException | IllegalArgumentException e) { + System.out.println("토큰 검증 실패"); + } + return false; + } + + /** + * 토큰의 Claims (내부 정보)를 꺼내는 내부 메서드 + */ + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public JwtPayload parseToken(String token) { + Claims claims = parseClaims(token); + return new JwtPayload( + claims.getSubject(), // id + (String) claims.get("email"), + (String) claims.get("name"), + (String) claims.get("role") + ); + } + +} \ No newline at end of file From d0a3cb1e66d6d2c27010780ca6d5a48f0b3b676f Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:23:21 +0900 Subject: [PATCH 02/15] =?UTF-8?q?SECURITY:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?(=ED=86=A0=ED=81=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=BF=A0=ED=82=A4=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AdminLogoutCommandService.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java diff --git a/src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java b/src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java new file mode 100644 index 0000000..0af73a5 --- /dev/null +++ b/src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java @@ -0,0 +1,22 @@ +package dev.admin.admin.service.command; + +import dev.admin.admin.repository.TokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminLogoutCommandService { + + private final TokenRepository tokenRepository; + + /** + * 로그아웃: refreshToken 삭제 + */ + public void logout(String refreshToken) { + // 저장소에 토큰이 존재할 때만 삭제 + if (tokenRepository.exists(refreshToken)) { + tokenRepository.delete(refreshToken); + } + } +} From fb39290dd8e73f572a11692495037aed03ad7c24 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:23:38 +0900 Subject: [PATCH 03/15] =?UTF-8?q?SECURITY:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/filter/JwtAuthenticationFilter.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/main/java/dev/admin/admin/filter/JwtAuthenticationFilter.java diff --git a/src/main/java/dev/admin/admin/filter/JwtAuthenticationFilter.java b/src/main/java/dev/admin/admin/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..2dafd56 --- /dev/null +++ b/src/main/java/dev/admin/admin/filter/JwtAuthenticationFilter.java @@ -0,0 +1,68 @@ +package dev.admin.admin.filter; + +import dev.admin.admin.security.AdminDetailsService; +import dev.admin.admin.utils.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final AdminDetailsService adminDetailsService; + + /** + * 요청 헤더에서 JWT 꺼내서 → 유효하면 SecurityContext에 인증 정보 저장 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String token = null; + + // 1. Authorization 헤더에서 Bearer 토큰 추출 + String authHeader = request.getHeader("Authorization"); + if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + } + + // 2. Authorization 헤더가 없으면 쿠키에서 accessToken 추출 + if (token == null && request.getCookies() != null) { + for (var cookie : request.getCookies()) { + if (cookie.getName().equals("accessToken")) { + token = cookie.getValue(); + break; + } + } + } + + // 3. 토큰이 있고, 유효한 경우 + if (token != null && jwtUtil.validateToken(token)) { + + String email = jwtUtil.getEmailFromToken(token); + var adminDetails = adminDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(adminDetails, null, adminDetails.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file From 7fbe97ba957d01dace75acae1690920bb024a7a6 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:23:52 +0900 Subject: [PATCH 04/15] =?UTF-8?q?SECURITY:=20RefreshToken=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20InMemory=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InMemoryTokenRepository.java | 33 +++++++++++++++++++ .../admin/repository/TokenRepository.java | 8 +++++ 2 files changed, 41 insertions(+) create mode 100644 src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java create mode 100644 src/main/java/dev/admin/admin/repository/TokenRepository.java diff --git a/src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java b/src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java new file mode 100644 index 0000000..4872118 --- /dev/null +++ b/src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java @@ -0,0 +1,33 @@ +package dev.admin.admin.repository; + +import org.springframework.stereotype.Repository; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Repository +public class InMemoryTokenRepository implements TokenRepository { + + // 메모리에 refresh token 저장 (email -> token) + private final Map store = new ConcurrentHashMap<>(); + + @Override + public void save(String email, String refreshToken) { + store.put(email, refreshToken); + } + + @Override + public boolean exists(String refreshToken) { + return store.containsValue(refreshToken); + } + + @Override + public void delete(String refreshToken) { + store.entrySet().removeIf(entry -> entry.getValue().equals(refreshToken)); + } + + @Override + public String findByEmail(String email) { + return store.get(email); + } +} diff --git a/src/main/java/dev/admin/admin/repository/TokenRepository.java b/src/main/java/dev/admin/admin/repository/TokenRepository.java new file mode 100644 index 0000000..b956233 --- /dev/null +++ b/src/main/java/dev/admin/admin/repository/TokenRepository.java @@ -0,0 +1,8 @@ +package dev.admin.admin.repository; + +public interface TokenRepository { + void save(String email, String refreshToken); + boolean exists(String refreshToken); + void delete(String refreshToken); + String findByEmail(String email); // 로그인 시에도 사용되므로 유지 +} From 87c20f980bcc2b501519faaf92e72d4d9fab25d0 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:25:21 +0900 Subject: [PATCH 05/15] =?UTF-8?q?CHORE:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20Rol?= =?UTF-8?q?e=20=EA=B4=80=EB=A0=A8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/dev/admin/admin/entity/Admin.java | 10 ++++++++++ src/main/java/dev/admin/admin/entity/Role.java | 6 ++++++ .../dev/admin/admin/repository/AdminRepository.java | 7 +++++++ 3 files changed, 23 insertions(+) create mode 100644 src/main/java/dev/admin/admin/entity/Role.java diff --git a/src/main/java/dev/admin/admin/entity/Admin.java b/src/main/java/dev/admin/admin/entity/Admin.java index 08336a7..2e57b7b 100644 --- a/src/main/java/dev/admin/admin/entity/Admin.java +++ b/src/main/java/dev/admin/admin/entity/Admin.java @@ -3,6 +3,8 @@ import dev.admin.global.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; +import dev.admin.admin.entity.Role; + @Entity @Getter @@ -20,4 +22,12 @@ public class Admin extends BaseTimeEntity { @Column(nullable = false) private String password; + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + } diff --git a/src/main/java/dev/admin/admin/entity/Role.java b/src/main/java/dev/admin/admin/entity/Role.java new file mode 100644 index 0000000..96344a2 --- /dev/null +++ b/src/main/java/dev/admin/admin/entity/Role.java @@ -0,0 +1,6 @@ +package dev.admin.admin.entity; + +public enum Role { + ADMIN, // 관리자 + // 필요하면 다른 역할도 추가 가능 +} diff --git a/src/main/java/dev/admin/admin/repository/AdminRepository.java b/src/main/java/dev/admin/admin/repository/AdminRepository.java index 282236d..c851e66 100644 --- a/src/main/java/dev/admin/admin/repository/AdminRepository.java +++ b/src/main/java/dev/admin/admin/repository/AdminRepository.java @@ -3,5 +3,12 @@ import dev.admin.admin.entity.Admin; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface AdminRepository extends JpaRepository { + + /** + * 이메일로 관리자 정보 조회 (로그인용) + */ + Optional findByEmail(String email); } From fc95e086b66418e9b48f9f9d3fc58c2eaf0e4cd0 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:25:59 +0900 Subject: [PATCH 06/15] =?UTF-8?q?SECURITY:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20(/admin/me)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/response/AdminInfoResponse.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/java/dev/admin/admin/dto/response/AdminInfoResponse.java diff --git a/src/main/java/dev/admin/admin/dto/response/AdminInfoResponse.java b/src/main/java/dev/admin/admin/dto/response/AdminInfoResponse.java new file mode 100644 index 0000000..6e4bad6 --- /dev/null +++ b/src/main/java/dev/admin/admin/dto/response/AdminInfoResponse.java @@ -0,0 +1,31 @@ +package dev.admin.admin.dto.response; + +public class AdminInfoResponse { + private String id; + private String email; + private String name; + private String role; + + public AdminInfoResponse(String id, String email, String name, String role) { + this.id = id; + this.email = email; + this.name = name; + this.role = role; + } + + public String getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getName() { + return name; + } + + public String getRole() { + return role; + } +} From ef9d4fbe3414a09a3dd4a5bad46fb7b446bc933f Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:27:02 +0900 Subject: [PATCH 07/15] =?UTF-8?q?FIX:=20Enum=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dev/admin/global/apiPayload/code/status/ErrorStatus.java | 3 +++ src/main/java/dev/admin/transaction/enums/TransactionType.java | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java index e9738b5..3b45809 100644 --- a/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java @@ -21,6 +21,9 @@ public enum ErrorStatus implements BaseErrorCode { _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + // Login + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "A001", "비밀번호가 일치하지 않습니다."), + // Notice NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE404", "공지사항을 찾을 수 없습니다."), diff --git a/src/main/java/dev/admin/transaction/enums/TransactionType.java b/src/main/java/dev/admin/transaction/enums/TransactionType.java index 0a09734..d7d8b29 100644 --- a/src/main/java/dev/admin/transaction/enums/TransactionType.java +++ b/src/main/java/dev/admin/transaction/enums/TransactionType.java @@ -1,5 +1,5 @@ package dev.admin.transaction.enums; public enum TransactionType { - DEPOSIT, WITHDRAW, CONVERT, PURCHASE, REFUND, RECEIVE + DEPOSIT, WITHDRAW, CONVERT, PURCHASE, REFUND, RECEIVE, AUTO_CONVERT } From f93e2d3c7c42b4d88fb6005ac9824c808b29e001 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:28:02 +0900 Subject: [PATCH 08/15] =?UTF-8?q?CONFIG:=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20-=20JWT=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=B4=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/global/config/SecurityConfig.java | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/java/dev/admin/global/config/SecurityConfig.java b/src/main/java/dev/admin/global/config/SecurityConfig.java index a388576..6d77d12 100644 --- a/src/main/java/dev/admin/global/config/SecurityConfig.java +++ b/src/main/java/dev/admin/global/config/SecurityConfig.java @@ -1,22 +1,46 @@ package dev.admin.global.config; +import dev.admin.admin.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; - +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .csrf(csrf -> csrf.disable()) - .formLogin(Customizer.withDefaults()) - .httpBasic(Customizer.withDefaults()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/login", "/admin/logout").permitAll() + .requestMatchers("/admin/me", "/admin-api/**").authenticated() + .anyRequest().denyAll() + ) + .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .logout(logout -> logout.disable()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } } From 9376290fa14ae76e3e28712c3618d796bcf88378 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sat, 31 May 2025 22:29:04 +0900 Subject: [PATCH 09/15] =?UTF-8?q?CHORE:=20JWT=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20JJWT=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle b/build.gradle index 31053fe..b928f9e 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,11 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.core:jackson-annotations' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // JWT (JJWT) 라이브러리 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 직렬화용 } tasks.named('test') { From 99bfab243c4f17e391200683b52f10fe229735c8 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sun, 1 Jun 2025 12:25:39 +0900 Subject: [PATCH 10/15] =?UTF-8?q?REFACTOR:=20TransactionType.java=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/dev/admin/transaction/enums/TransactionType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/admin/transaction/enums/TransactionType.java b/src/main/java/dev/admin/transaction/enums/TransactionType.java index d7d8b29..0a09734 100644 --- a/src/main/java/dev/admin/transaction/enums/TransactionType.java +++ b/src/main/java/dev/admin/transaction/enums/TransactionType.java @@ -1,5 +1,5 @@ package dev.admin.transaction.enums; public enum TransactionType { - DEPOSIT, WITHDRAW, CONVERT, PURCHASE, REFUND, RECEIVE, AUTO_CONVERT + DEPOSIT, WITHDRAW, CONVERT, PURCHASE, REFUND, RECEIVE } From 5214f5c7e93917cdc4ffb40ef0431f4203675860 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Sun, 1 Jun 2025 23:21:32 +0900 Subject: [PATCH 11/15] =?UTF-8?q?REFACTOR:=20ErrorStatus=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dev/admin/global/apiPayload/code/status/ErrorStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java index 3b45809..eff84b9 100644 --- a/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/dev/admin/global/apiPayload/code/status/ErrorStatus.java @@ -22,7 +22,7 @@ public enum ErrorStatus implements BaseErrorCode { _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), // Login - INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "A001", "비밀번호가 일치하지 않습니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "AUTH401", "비밀번호가 일치하지 않습니다."), // Notice NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE404", "공지사항을 찾을 수 없습니다."), From 80c0c4b8484e2535ab2d8636def98578a9f79773 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Mon, 2 Jun 2025 10:35:29 +0900 Subject: [PATCH 12/15] =?UTF-8?q?REFACTOR:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20Service=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=91=ED=95=A9=20=EB=B0=8F=20Controller=20ApiRe?= =?UTF-8?q?sponse=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminAuthController.java | 37 +++++++++---------- ...vice.java => AdminAuthCommandService.java} | 11 +++++- .../command/AdminLogoutCommandService.java | 22 ----------- 3 files changed, 28 insertions(+), 42 deletions(-) rename src/main/java/dev/admin/admin/service/command/{AdminLoginCommandService.java => AdminAuthCommandService.java} (83%) delete mode 100644 src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java diff --git a/src/main/java/dev/admin/admin/controller/AdminAuthController.java b/src/main/java/dev/admin/admin/controller/AdminAuthController.java index a15242b..14380f8 100644 --- a/src/main/java/dev/admin/admin/controller/AdminAuthController.java +++ b/src/main/java/dev/admin/admin/controller/AdminAuthController.java @@ -4,9 +4,9 @@ import dev.admin.admin.dto.response.AdminInfoResponse; import dev.admin.admin.dto.response.JwtDto; import dev.admin.admin.dto.response.JwtPayload; -import dev.admin.admin.service.command.AdminLoginCommandService; -import dev.admin.admin.service.command.AdminLogoutCommandService; +import dev.admin.admin.service.command.AdminAuthCommandService; import dev.admin.admin.utils.JwtUtil; +import dev.admin.global.apiPayload.ApiResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseCookie; @@ -18,13 +18,12 @@ @RequiredArgsConstructor public class AdminAuthController { - private final AdminLoginCommandService adminLoginCommandService; - private final AdminLogoutCommandService adminLogoutCommandService; + private final AdminAuthCommandService authCommandService; private final JwtUtil jwtUtil; @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequestDto requestDto, HttpServletResponse response) { - JwtDto tokenDto = adminLoginCommandService.login(requestDto); + public ResponseEntity> login(@RequestBody LoginRequestDto requestDto, HttpServletResponse response) { + JwtDto tokenDto = authCommandService.login(requestDto); // 쿠키 설정은 그대로 유지 ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", tokenDto.getAccessToken()) @@ -47,14 +46,14 @@ public ResponseEntity login(@RequestBody LoginRequestDto requestDto, Htt response.addHeader("Set-Cookie", refreshTokenCookie.toString()); // ✅ 응답 바디에 토큰도 포함 - return ResponseEntity.ok(tokenDto); + return ResponseEntity.ok(ApiResponse.onSuccess(tokenDto)); } @PostMapping("/logout") - public ResponseEntity logout(@CookieValue("refreshToken") String refreshToken, + public ResponseEntity> logout(@CookieValue("refreshToken") String refreshToken, HttpServletResponse response) { // 서비스 호출: refreshToken 기반 로그아웃 처리 (DB/Redis 삭제 등) - adminLogoutCommandService.logout(refreshToken); + authCommandService.logout(refreshToken); // accessToken & refreshToken 쿠키 삭제 ResponseCookie clearAccessToken = ResponseCookie.from("accessToken", "") @@ -76,20 +75,20 @@ public ResponseEntity logout(@CookieValue("refreshToken") String refreshTo response.addHeader("Set-Cookie", clearAccessToken.toString()); response.addHeader("Set-Cookie", clearRefreshToken.toString()); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(ApiResponse.onSuccess(null)); } @GetMapping("/me") - public ResponseEntity me(@CookieValue("accessToken") String accessToken) { + public ResponseEntity> me(@CookieValue("accessToken") String accessToken) { JwtPayload payload = jwtUtil.parseToken(accessToken); - return ResponseEntity.ok( - new AdminInfoResponse( - payload.getSub(), - payload.getEmail(), - payload.getName(), - payload.getRole() - ) + + AdminInfoResponse info = new AdminInfoResponse( + payload.getSub(), + payload.getEmail(), + payload.getName(), + payload.getRole() ); - } + return ResponseEntity.ok(ApiResponse.onSuccess(info)); + } } diff --git a/src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java b/src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java similarity index 83% rename from src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java rename to src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java index 9e5daae..d74052b 100644 --- a/src/main/java/dev/admin/admin/service/command/AdminLoginCommandService.java +++ b/src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java @@ -14,7 +14,7 @@ @Service @RequiredArgsConstructor -public class AdminLoginCommandService { +public class AdminAuthCommandService { private final AdminRepository adminRepository; private final PasswordEncoder passwordEncoder; @@ -42,5 +42,14 @@ public JwtDto login(LoginRequestDto requestDto) { return new JwtDto(accessToken, refreshToken); } + /** + * 로그아웃: refreshToken 삭제 + */ + public void logout(String refreshToken) { + // 저장소에 토큰이 존재할 때만 삭제 + if (tokenRepository.exists(refreshToken)) { + tokenRepository.delete(refreshToken); + } + } } diff --git a/src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java b/src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java deleted file mode 100644 index 0af73a5..0000000 --- a/src/main/java/dev/admin/admin/service/command/AdminLogoutCommandService.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.admin.admin.service.command; - -import dev.admin.admin.repository.TokenRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AdminLogoutCommandService { - - private final TokenRepository tokenRepository; - - /** - * 로그아웃: refreshToken 삭제 - */ - public void logout(String refreshToken) { - // 저장소에 토큰이 존재할 때만 삭제 - if (tokenRepository.exists(refreshToken)) { - tokenRepository.delete(refreshToken); - } - } -} From 257282bbdbd993423fe06f28842cf269f7b1454e Mon Sep 17 00:00:00 2001 From: amy8883 Date: Mon, 2 Jun 2025 10:53:57 +0900 Subject: [PATCH 13/15] =?UTF-8?q?REFACTOR:=20SecurityConfig=20-=20Swagger?= =?UTF-8?q?=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/dev/admin/global/config/SecurityConfig.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/dev/admin/global/config/SecurityConfig.java b/src/main/java/dev/admin/global/config/SecurityConfig.java index 6d77d12..eccbe8d 100644 --- a/src/main/java/dev/admin/global/config/SecurityConfig.java +++ b/src/main/java/dev/admin/global/config/SecurityConfig.java @@ -34,8 +34,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth - .requestMatchers("/admin/login", "/admin/logout").permitAll() - .requestMatchers("/admin/me", "/admin-api/**").authenticated() + .requestMatchers( + "/admin/login", + "/admin/logout", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**" + ).permitAll() .requestMatchers("/admin/me", "/admin-api/**").authenticated() .anyRequest().denyAll() ) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) From fa168375248e47217978fcecbb846382de6b9e67 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Wed, 4 Jun 2025 13:54:39 +0900 Subject: [PATCH 14/15] =?UTF-8?q?REFACTOR:=20Redis=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++ .../admin/controller/AdminAuthController.java | 4 +- .../repository/InMemoryTokenRepository.java | 33 -------------- .../repository/RedisTokenRepository.java | 44 +++++++++++++++++++ .../admin/repository/TokenRepository.java | 10 +++-- .../command/AdminAuthCommandService.java | 20 ++++++--- .../dev/admin/global/config/RedisConfig.java | 22 ++++++++++ .../admin/global/config/SecurityConfig.java | 2 + 8 files changed, 95 insertions(+), 44 deletions(-) delete mode 100644 src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java create mode 100644 src/main/java/dev/admin/admin/repository/RedisTokenRepository.java create mode 100644 src/main/java/dev/admin/global/config/RedisConfig.java diff --git a/build.gradle b/build.gradle index b928f9e..fc404d5 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,10 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 직렬화용 + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + } tasks.named('test') { diff --git a/src/main/java/dev/admin/admin/controller/AdminAuthController.java b/src/main/java/dev/admin/admin/controller/AdminAuthController.java index 14380f8..9bfdce5 100644 --- a/src/main/java/dev/admin/admin/controller/AdminAuthController.java +++ b/src/main/java/dev/admin/admin/controller/AdminAuthController.java @@ -58,7 +58,7 @@ public ResponseEntity> logout(@CookieValue("refreshToken") Str // accessToken & refreshToken 쿠키 삭제 ResponseCookie clearAccessToken = ResponseCookie.from("accessToken", "") .httpOnly(true) - .secure(true) + .secure(false) .path("/") .maxAge(0) .sameSite("Lax") @@ -66,7 +66,7 @@ public ResponseEntity> logout(@CookieValue("refreshToken") Str ResponseCookie clearRefreshToken = ResponseCookie.from("refreshToken", "") .httpOnly(true) - .secure(true) + .secure(false) .path("/") .maxAge(0) .sameSite("Lax") diff --git a/src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java b/src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java deleted file mode 100644 index 4872118..0000000 --- a/src/main/java/dev/admin/admin/repository/InMemoryTokenRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.admin.admin.repository; - -import org.springframework.stereotype.Repository; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Repository -public class InMemoryTokenRepository implements TokenRepository { - - // 메모리에 refresh token 저장 (email -> token) - private final Map store = new ConcurrentHashMap<>(); - - @Override - public void save(String email, String refreshToken) { - store.put(email, refreshToken); - } - - @Override - public boolean exists(String refreshToken) { - return store.containsValue(refreshToken); - } - - @Override - public void delete(String refreshToken) { - store.entrySet().removeIf(entry -> entry.getValue().equals(refreshToken)); - } - - @Override - public String findByEmail(String email) { - return store.get(email); - } -} diff --git a/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java b/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java new file mode 100644 index 0000000..314617d --- /dev/null +++ b/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java @@ -0,0 +1,44 @@ +package dev.admin.admin.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +@Repository +public class RedisTokenRepository implements TokenRepository { + + private final StringRedisTemplate redis; + + @Override + public void save(String key, String value) { + log.info("🔥 RedisTokenRepository.save() 실행됨"); + log.info("📝 저장할 key = {}", key); + log.info("📝 저장할 value = {}", value); + + // ⏱️ 30분 TTL 추가 (설정 안 하면 expire 안 됨) + redis.opsForValue().set(key, value, Duration.ofMinutes(30)); + } + + @Override + public Optional find(String key) { + return Optional.ofNullable(redis.opsForValue().get(key)); + } + + @Override + public void delete(String key) { + redis.delete(key); + } + + @Override + public boolean exists(String key) { + Boolean result = redis.hasKey(key); + return result != null && result; + } +} + diff --git a/src/main/java/dev/admin/admin/repository/TokenRepository.java b/src/main/java/dev/admin/admin/repository/TokenRepository.java index b956233..b44e009 100644 --- a/src/main/java/dev/admin/admin/repository/TokenRepository.java +++ b/src/main/java/dev/admin/admin/repository/TokenRepository.java @@ -1,8 +1,10 @@ package dev.admin.admin.repository; +import java.util.Optional; + public interface TokenRepository { - void save(String email, String refreshToken); - boolean exists(String refreshToken); - void delete(String refreshToken); - String findByEmail(String email); // 로그인 시에도 사용되므로 유지 + void save(String key, String value); + Optional find(String key); + void delete(String key); + boolean exists(String key); } diff --git a/src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java b/src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java index d74052b..f9e639f 100644 --- a/src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java +++ b/src/main/java/dev/admin/admin/service/command/AdminAuthCommandService.java @@ -37,7 +37,8 @@ public JwtDto login(LoginRequestDto requestDto) { ); String refreshToken = jwtUtil.generateRefreshToken(); - tokenRepository.save(admin.getEmail(), refreshToken); + + tokenRepository.save(admin.getEmail() + "_refreshToken", refreshToken); // refreshToken 저장 return new JwtDto(accessToken, refreshToken); } @@ -46,10 +47,19 @@ public JwtDto login(LoginRequestDto requestDto) { * 로그아웃: refreshToken 삭제 */ public void logout(String refreshToken) { - // 저장소에 토큰이 존재할 때만 삭제 - if (tokenRepository.exists(refreshToken)) { - tokenRepository.delete(refreshToken); + // Redis에서 RefreshToken 삭제 + String redisRefreshKey = "refresh:" + refreshToken; + if (tokenRepository.exists(redisRefreshKey)) { + tokenRepository.delete(redisRefreshKey); } - } + // 이메일 추출 + String email = jwtUtil.getEmailFromToken(refreshToken); + + // 그러나 원하면 Redis에서 직접 삭제할 수 있음 (Access Token을 Redis에 저장한 경우) + String redisAccessKey = email + "_accessToken"; + if (tokenRepository.exists(redisAccessKey)) { + tokenRepository.delete(redisAccessKey); // Redis에서 AccessToken 삭제 + } + } } diff --git a/src/main/java/dev/admin/global/config/RedisConfig.java b/src/main/java/dev/admin/global/config/RedisConfig.java new file mode 100644 index 0000000..0cd5261 --- /dev/null +++ b/src/main/java/dev/admin/global/config/RedisConfig.java @@ -0,0 +1,22 @@ +package dev.admin.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + + // RedisTemplate 등록 + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} \ No newline at end of file diff --git a/src/main/java/dev/admin/global/config/SecurityConfig.java b/src/main/java/dev/admin/global/config/SecurityConfig.java index eccbe8d..5e0e3e7 100644 --- a/src/main/java/dev/admin/global/config/SecurityConfig.java +++ b/src/main/java/dev/admin/global/config/SecurityConfig.java @@ -34,6 +34,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth + .requestMatchers("/test/**").permitAll() // ✅ 여기를 추가! + .requestMatchers( "/admin/login", "/admin/logout", From 94a980ef56c5529a4fb603a27b4a5687e95d3585 Mon Sep 17 00:00:00 2001 From: amy8883 Date: Wed, 4 Jun 2025 14:03:54 +0900 Subject: [PATCH 15/15] =?UTF-8?q?REFACTOR:=20ApiResponse=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminAuthController.java | 16 ++++++---------- .../admin/repository/RedisTokenRepository.java | 5 ----- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/main/java/dev/admin/admin/controller/AdminAuthController.java b/src/main/java/dev/admin/admin/controller/AdminAuthController.java index 9bfdce5..90d7c08 100644 --- a/src/main/java/dev/admin/admin/controller/AdminAuthController.java +++ b/src/main/java/dev/admin/admin/controller/AdminAuthController.java @@ -22,10 +22,9 @@ public class AdminAuthController { private final JwtUtil jwtUtil; @PostMapping("/login") - public ResponseEntity> login(@RequestBody LoginRequestDto requestDto, HttpServletResponse response) { + public ApiResponse login(@RequestBody LoginRequestDto requestDto, HttpServletResponse response) { JwtDto tokenDto = authCommandService.login(requestDto); - // 쿠키 설정은 그대로 유지 ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", tokenDto.getAccessToken()) .httpOnly(true) .secure(false) @@ -45,17 +44,14 @@ public ResponseEntity> login(@RequestBody LoginRequestDto re response.addHeader("Set-Cookie", accessTokenCookie.toString()); response.addHeader("Set-Cookie", refreshTokenCookie.toString()); - // ✅ 응답 바디에 토큰도 포함 - return ResponseEntity.ok(ApiResponse.onSuccess(tokenDto)); + return ApiResponse.onSuccess(tokenDto); } @PostMapping("/logout") - public ResponseEntity> logout(@CookieValue("refreshToken") String refreshToken, + public ApiResponse logout(@CookieValue("refreshToken") String refreshToken, HttpServletResponse response) { - // 서비스 호출: refreshToken 기반 로그아웃 처리 (DB/Redis 삭제 등) authCommandService.logout(refreshToken); - // accessToken & refreshToken 쿠키 삭제 ResponseCookie clearAccessToken = ResponseCookie.from("accessToken", "") .httpOnly(true) .secure(false) @@ -75,11 +71,11 @@ public ResponseEntity> logout(@CookieValue("refreshToken") Str response.addHeader("Set-Cookie", clearAccessToken.toString()); response.addHeader("Set-Cookie", clearRefreshToken.toString()); - return ResponseEntity.ok(ApiResponse.onSuccess(null)); + return ApiResponse.onSuccess(null); } @GetMapping("/me") - public ResponseEntity> me(@CookieValue("accessToken") String accessToken) { + public ApiResponse me(@CookieValue("accessToken") String accessToken) { JwtPayload payload = jwtUtil.parseToken(accessToken); AdminInfoResponse info = new AdminInfoResponse( @@ -89,6 +85,6 @@ public ResponseEntity> me(@CookieValue("accessTok payload.getRole() ); - return ResponseEntity.ok(ApiResponse.onSuccess(info)); + return ApiResponse.onSuccess(info); } } diff --git a/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java b/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java index 314617d..cc4b25f 100644 --- a/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java +++ b/src/main/java/dev/admin/admin/repository/RedisTokenRepository.java @@ -17,11 +17,6 @@ public class RedisTokenRepository implements TokenRepository { @Override public void save(String key, String value) { - log.info("🔥 RedisTokenRepository.save() 실행됨"); - log.info("📝 저장할 key = {}", key); - log.info("📝 저장할 value = {}", value); - - // ⏱️ 30분 TTL 추가 (설정 안 하면 expire 안 됨) redis.opsForValue().set(key, value, Duration.ofMinutes(30)); }