From 68233402fc2e0375f7f036dc094018724d2dc173 Mon Sep 17 00:00:00 2001 From: Hyojin Ahn Date: Tue, 27 Jan 2026 13:46:28 -0500 Subject: [PATCH] [Auth] Changed JWT into Cookie --- .../com/goi/erp/auth/AuthCookieService.java | 109 +++++++ .../erp/auth/AuthenticationController.java | 55 ++-- .../goi/erp/auth/AuthenticationResponse.java | 14 +- .../goi/erp/auth/AuthenticationService.java | 274 +++++++++--------- .../auth/SystemAuthenticationResponseDto.java | 19 ++ .../erp/config/JwtAuthenticationFilter.java | 112 +++---- .../java/com/goi/erp/config/JwtService.java | 161 +++++----- .../com/goi/erp/config/LogoutService.java | 50 ++-- .../goi/erp/config/SecurityConfiguration.java | 68 +++-- .../erp/employee/EmployeeRoleRepository.java | 13 +- .../com/goi/erp/token/TokenRepository.java | 25 +- .../java/com/goi/erp/token/TokenType.java | 2 + src/main/resources/application.yml | 6 +- 13 files changed, 548 insertions(+), 360 deletions(-) create mode 100644 src/main/java/com/goi/erp/auth/AuthCookieService.java create mode 100644 src/main/java/com/goi/erp/auth/SystemAuthenticationResponseDto.java diff --git a/src/main/java/com/goi/erp/auth/AuthCookieService.java b/src/main/java/com/goi/erp/auth/AuthCookieService.java new file mode 100644 index 0000000..5fa95a1 --- /dev/null +++ b/src/main/java/com/goi/erp/auth/AuthCookieService.java @@ -0,0 +1,109 @@ +package com.goi.erp.auth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.Optional; + +@Service +public class AuthCookieService { + + public static final String AUTH_COOKIE_NAME = "AUTH_TOKEN"; + public static final String REFRESH_COOKIE_NAME = "REFRESH_TOKEN"; + + @Value("${auth.cookie.secure:true}") + private boolean secure; + + @Value("${auth.cookie.same-site:Lax}") + private String sameSite; + + @Value("${auth.cookie.path:/}") + private String path; + + // ===================== Access Token ===================== + + public void addAuthCookie(HttpServletResponse response, String jwt) { + addCookie(response, AUTH_COOKIE_NAME, jwt, -1); + } + + public void clearAuthCookie(HttpServletResponse response) { + addCookie(response, AUTH_COOKIE_NAME, null, 0); + } + + public Optional extractJwt(HttpServletRequest request) { + return extractCookie(request, AUTH_COOKIE_NAME); + } + + // ===================== Refresh Token ===================== + + public void addRefreshCookie(HttpServletResponse response, String refreshToken) { + addCookie(response, REFRESH_COOKIE_NAME, refreshToken, -1); + } + + public void clearRefreshCookie(HttpServletResponse response) { + addCookie(response, REFRESH_COOKIE_NAME, null, 0); + } + + public Optional extractRefreshToken(HttpServletRequest request) { + return extractCookie(request, REFRESH_COOKIE_NAME); + } + + // ===================== 내부 공통 ===================== + + private void addCookie( + HttpServletResponse response, + String name, + String value, + int maxAge + ) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(secure); + cookie.setPath(path); + cookie.setMaxAge(maxAge); + + response.addCookie(cookie); + addSameSiteAttribute(response, cookie); + } + + private Optional extractCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) { + return Optional.empty(); + } + + return Arrays.stream(request.getCookies()) + .filter(c -> name.equals(c.getName())) + .map(Cookie::getValue) + .filter(v -> v != null && !v.isBlank()) + .findFirst(); + } + + /** + * SameSite 수동 세팅 (Servlet Cookie API 한계) + */ + private void addSameSiteAttribute(HttpServletResponse response, Cookie cookie) { + StringBuilder sb = new StringBuilder(); + sb.append(cookie.getName()).append("="); + sb.append(cookie.getValue() == null ? "" : cookie.getValue()); + sb.append("; Path=").append(cookie.getPath()); + sb.append("; HttpOnly"); + + if (cookie.getSecure()) { + sb.append("; Secure"); + } + + if (sameSite != null && !sameSite.isBlank()) { + sb.append("; SameSite=").append(sameSite); + } + + if (cookie.getMaxAge() == 0) { + sb.append("; Max-Age=0"); + } + + response.addHeader("Set-Cookie", sb.toString()); + } +} diff --git a/src/main/java/com/goi/erp/auth/AuthenticationController.java b/src/main/java/com/goi/erp/auth/AuthenticationController.java index 416a289..718d3d5 100644 --- a/src/main/java/com/goi/erp/auth/AuthenticationController.java +++ b/src/main/java/com/goi/erp/auth/AuthenticationController.java @@ -4,10 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.io.IOException; @@ -16,27 +13,37 @@ import java.io.IOException; @RequiredArgsConstructor public class AuthenticationController { - private final AuthenticationService service; + private final AuthenticationService authenticationService; -// @PostMapping("/register") -// public ResponseEntity register( -// @RequestBody RegisterRequest request -// ) { -// return ResponseEntity.ok(service.register(request)); -// } - @PostMapping("/authenticate") - public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { - return ResponseEntity.ok(service.authenticate(request)); - } + // ===================== 로그인 ===================== - @PostMapping("/refresh-token") - public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { - service.refreshToken(request, response); - } - - @PostMapping("/authenticate/system") - public AuthenticationResponse authenticateSystem(@RequestBody SystemAuthenticationRequestDto request) { - return service.authenticateSystem(request); - } + @PostMapping("/login") + public ResponseEntity authenticate( + @RequestBody AuthenticationRequest request, + HttpServletResponse response + ) { + AuthenticationResponse authResponse = authenticationService.authenticate(request, response); + return ResponseEntity.ok(authResponse); + } + // ===================== Refresh Token ===================== + + @PostMapping("/token/refresh") + public void refreshToken( + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + authenticationService.refreshToken(request, response); + } + + // ===================== System Token ===================== + + @PostMapping("/login/system") + public ResponseEntity authenticateSystem( + @RequestBody SystemAuthenticationRequestDto request + ) { + return ResponseEntity.ok( + authenticationService.authenticateSystem(request) + ); + } } diff --git a/src/main/java/com/goi/erp/auth/AuthenticationResponse.java b/src/main/java/com/goi/erp/auth/AuthenticationResponse.java index 6072fbd..a1009e8 100644 --- a/src/main/java/com/goi/erp/auth/AuthenticationResponse.java +++ b/src/main/java/com/goi/erp/auth/AuthenticationResponse.java @@ -1,19 +1,23 @@ package com.goi.erp.auth; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @Builder @AllArgsConstructor @NoArgsConstructor public class AuthenticationResponse { - @JsonProperty("access_token") - private String accessToken; - @JsonProperty("refresh_token") - private String refreshToken; + private boolean authenticated; + + private String loginId; + private String firstName; + private String lastName; + + private List roles; } diff --git a/src/main/java/com/goi/erp/auth/AuthenticationService.java b/src/main/java/com/goi/erp/auth/AuthenticationService.java index 5da2eb7..7fd6bba 100644 --- a/src/main/java/com/goi/erp/auth/AuthenticationService.java +++ b/src/main/java/com/goi/erp/auth/AuthenticationService.java @@ -1,190 +1,204 @@ package com.goi.erp.auth; -import com.fasterxml.jackson.databind.ObjectMapper; import com.goi.erp.common.exception.InvalidPasswordException; import com.goi.erp.common.exception.UserNotFoundException; import com.goi.erp.config.JwtService; import com.goi.erp.config.SecuritySystemClientsProperties; -import com.goi.erp.token.Token; -import com.goi.erp.token.TokenRepository; -import com.goi.erp.token.TokenType; import com.goi.erp.employee.Employee; import com.goi.erp.employee.EmployeeRepository; import com.goi.erp.employee.EmployeeRole; import com.goi.erp.employee.EmployeeRoleRepository; import com.goi.erp.role.RolePermissionRepository; +import com.goi.erp.token.Token; +import com.goi.erp.token.TokenRepository; +import com.goi.erp.token.TokenType; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; import java.util.List; import java.util.stream.Collectors; @Service +@Transactional @RequiredArgsConstructor public class AuthenticationService { - private final EmployeeRepository employeeRepository; + + private final EmployeeRepository employeeRepository; private final EmployeeRoleRepository employeeRoleRepository; + private final RolePermissionRepository rolePermissionRepository; private final TokenRepository tokenRepository; private final PasswordEncoder passwordEncoder; - private final RolePermissionRepository rolePermissionRepository; - + private final JwtService jwtService; private final SecuritySystemClientsProperties systemClientsProperties; -// private final AuthenticationManager authenticationManager; + private final AuthCookieService authCookieService; + private final UserDetailsService userDetailsService; -// public AuthenticationResponse register(RegisterRequest request) { -// var user = User.builder().firstname(request.getFirstname()).lastname(request.getLastname()) -// .email(request.getEmail()).password(passwordEncoder.encode(request.getPassword())) -// .role(request.getRole()).build(); -// var savedUser = repository.save(user); -// var jwtToken = jwtService.generateToken(user); -// var refreshToken = jwtService.generateRefreshToken(user); -// saveUserToken(savedUser, jwtToken); -// return AuthenticationResponse.builder().accessToken(jwtToken).refreshToken(refreshToken).build(); -// } + // ===================== 로그인 ===================== - // 로그인 처리 - public AuthenticationResponse authenticate(AuthenticationRequest request) { - // 1. Employee 조회 + public AuthenticationResponse authenticate( + AuthenticationRequest request, + HttpServletResponse response + ) { Employee employee = employeeRepository.findByEmpLoginId(request.getEmpLoginId()) .orElseThrow(() -> new UserNotFoundException("Employee not found")); - // 2. 비밀번호 검증 if (!passwordEncoder.matches(request.getEmpLoginPassword(), employee.getEmpLoginPassword())) { throw new InvalidPasswordException("Invalid password"); } - // 3. EmployeeRole 조회 → Role 이름 리스트 생성 - List activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + List activeRoles = + employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); - List roles = activeRoles.stream() - .map(er -> er.getRoleInfo().getRoleName()) - .collect(Collectors.toList()); + List roles = extractRoles(activeRoles); + List permissions = extractPermissions(activeRoles); - - // 4. Role → Permission 조회 - List permissions = activeRoles.stream() - .flatMap(er -> rolePermissionRepository.findByRoleId(er.getRoleInfo().getRoleId()).stream()) - .map(rp -> rp.getPermissionInfo().getPermModule() + ":" + rp.getPermissionInfo().getPermAction() + ":" + rp.getPermissionInfo().getPermScope()) - .distinct() - .collect(Collectors.toList()); - - // 5. generate token - String jwtToken = jwtService.generateToken(employee, roles, permissions); + String accessToken = jwtService.generateToken(employee, roles, permissions); String refreshToken = jwtService.generateRefreshToken(employee, roles, permissions); - // 기존 토큰 회수 및 새 토큰 저장 revokeAllEmployeeTokens(employee); - saveEmployeeToken(employee, jwtToken); + // refresh 도 저장 + saveEmployeeToken(employee, accessToken, TokenType.ACCESS); + saveEmployeeToken(employee, refreshToken, TokenType.REFRESH); + + + // Cookie로 내려줌 + authCookieService.addAuthCookie(response, accessToken); + authCookieService.addRefreshCookie(response, refreshToken); return AuthenticationResponse.builder() - .accessToken(jwtToken) - .refreshToken(refreshToken) + .authenticated(true) + .loginId(employee.getEmpLoginId()) + .firstName(employee.getEmpFirstName()) + .lastName(employee.getEmpLastName()) + .roles(roles) .build(); } - // JWT 토큰 저장 - private void saveEmployeeToken(Employee employee, String jwtToken) { + // ===================== Refresh Token ===================== + + public void refreshToken(HttpServletRequest request, HttpServletResponse response) { + + String refreshToken = authCookieService + .extractRefreshToken(request) + .orElse(null); + + if (refreshToken == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String loginId = jwtService.extractUsername(refreshToken); + if (loginId == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); + + if (!jwtService.isTokenValid(refreshToken, userDetails)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + Employee employee = employeeRepository.findByEmpLoginId(loginId) + .orElseThrow(() -> new UserNotFoundException("Employee not found")); + + List activeRoles = + employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + + List roles = extractRoles(activeRoles); + List permissions = extractPermissions(activeRoles); + + + String newAccessToken = jwtService.generateToken(employee, roles, permissions); + String newRefreshToken = jwtService.generateRefreshToken(employee, roles, permissions); + + // 기존 토큰 전부 revoke + revokeAllEmployeeTokens(employee); + + // 새 토큰 저장 + saveEmployeeToken(employee, newAccessToken, TokenType.ACCESS); + saveEmployeeToken(employee, newRefreshToken, TokenType.REFRESH); + + // 쿠키 갱신 + authCookieService.addAuthCookie(response, newAccessToken); + authCookieService.addRefreshCookie(response, newRefreshToken); + + // body 없음 + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + + // ===================== System Token ===================== + // OPR / CRM 등 서버간 호출용 (Header 기반) + + public SystemAuthenticationResponseDto authenticateSystem(SystemAuthenticationRequestDto request) { + + if (request.getClientId() == null || request.getClientSecret() == null) { + throw new InvalidPasswordException("Missing client credentials"); + } + + var clientConfig = systemClientsProperties.getClients().get(request.getClientId()); + if (clientConfig == null) { + throw new InvalidPasswordException("Invalid system client"); + } + + if (!clientConfig.getSecret().equals(request.getClientSecret())) { + throw new InvalidPasswordException("Invalid system secret"); + } + + String jwtToken = jwtService.generateSystemToken( + request.getClientId(), + clientConfig.getPermissions() + ); + + return SystemAuthenticationResponseDto.builder() + .accessToken(jwtToken) + .refreshToken(null) + .build(); + } + + + // ===================== 내부 헬퍼 ===================== + + private List extractRoles(List roles) { + return roles.stream() + .map(er -> er.getRoleInfo().getRoleName()) + .collect(Collectors.toList()); + } + + private List extractPermissions(List roles) { + return roles.stream() + .flatMap(er -> rolePermissionRepository + .findByRoleId(er.getRoleInfo().getRoleId()).stream()) + .map(rp -> rp.getPermissionInfo().getPermModule() + + ":" + rp.getPermissionInfo().getPermAction() + + ":" + rp.getPermissionInfo().getPermScope()) + .distinct() + .collect(Collectors.toList()); + } + + private void saveEmployeeToken(Employee employee, String jwtToken, TokenType tokenType) { Token token = Token.builder() .employee(employee) .token(jwtToken) - .tokenType(TokenType.BEARER) + .tokenType(tokenType) .expired(false) .revoked(false) .build(); tokenRepository.save(token); } - // 기존 토큰 회수 private void revokeAllEmployeeTokens(Employee employee) { - List validTokens = tokenRepository.findAllValidTokenByEmployee(employee.getEmpId()); - if (validTokens.isEmpty()) return; - - validTokens.forEach(token -> { - token.setExpired(true); - token.setRevoked(true); - }); - - tokenRepository.saveAll(validTokens); + tokenRepository.revokeAllValidTokensByEmployee(employee.getEmpId()); } - - // 리프레시 토큰 처리 - public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { - final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return; - } - - final String refreshToken = authHeader.substring(7); - String empLoginId = jwtService.extractUsername(refreshToken); - - if (empLoginId != null) { - Employee employee = employeeRepository.findByEmpLoginId(empLoginId) - .orElseThrow(() -> new RuntimeException("Employee not found")); - - if (jwtService.isTokenValid(refreshToken, employee)) { - // 3. EmployeeRole 조회 → Role 이름 리스트 생성 - List activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); - - List roles = activeRoles.stream() - .map(er -> er.getRoleInfo().getRoleName()) - .collect(Collectors.toList()); - - - // 4. Role → Permission 조회 - List permissions = activeRoles.stream() - .flatMap(er -> rolePermissionRepository.findByRoleId(er.getRoleInfo().getRoleId()).stream()) - .map(rp -> rp.getPermissionInfo().getPermModule() + ":" + rp.getPermissionInfo().getPermAction() + ":" + rp.getPermissionInfo().getPermScope()) - .distinct() - .collect(Collectors.toList()); - - // 5. generate token - String accessToken = jwtService.generateToken(employee, roles, permissions); - - revokeAllEmployeeTokens(employee); - saveEmployeeToken(employee, accessToken); - - AuthenticationResponse authResponse = AuthenticationResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - - new ObjectMapper().writeValue(response.getOutputStream(), authResponse); - } - } - } - - // 시스템 토큰 발급 (OPR/CRM 등 서버간 호출용) - public AuthenticationResponse authenticateSystem(SystemAuthenticationRequestDto request) { - - if (request.getClientId() == null || request.getClientSecret() == null) { - throw new InvalidPasswordException("Missing client credentials"); - } - - var clientConfig = systemClientsProperties.getClients().get(request.getClientId()); - if (clientConfig == null) { - throw new InvalidPasswordException("Invalid system client"); - } - - if (!clientConfig.getSecret().equals(request.getClientSecret())) { - throw new InvalidPasswordException("Invalid system secret"); - } - - List permissions = clientConfig.getPermissions(); // 예: ["H:R:A"] - - String jwtToken = jwtService.generateSystemToken(request.getClientId(), permissions); - - // system token은 보통 DB(TokenRepository)에 저장/폐기(revoke) 안 함 (짧게 발급 + 캐싱) - return AuthenticationResponse.builder() - .accessToken(jwtToken) - .refreshToken(null) - .build(); - } - } diff --git a/src/main/java/com/goi/erp/auth/SystemAuthenticationResponseDto.java b/src/main/java/com/goi/erp/auth/SystemAuthenticationResponseDto.java new file mode 100644 index 0000000..f253f0e --- /dev/null +++ b/src/main/java/com/goi/erp/auth/SystemAuthenticationResponseDto.java @@ -0,0 +1,19 @@ +package com.goi.erp.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SystemAuthenticationResponseDto { + + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("refresh_token") + private String refreshToken; +} diff --git a/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java index 28bdc9b..b49be1a 100644 --- a/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java @@ -1,91 +1,93 @@ package com.goi.erp.config; -import com.goi.erp.employee.Employee; -import com.goi.erp.employee.EmployeeDetails; -import com.goi.erp.employee.EmployeeRepository; -import com.goi.erp.employee.EmployeeRole; -import com.goi.erp.employee.EmployeeRoleRepository; -import com.goi.erp.token.TokenRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.lang.NonNull; -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.web.filter.OncePerRequestFilter; - 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.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.goi.erp.auth.AuthCookieService; import java.io.IOException; -import java.util.List; -import java.util.UUID; +import java.util.Optional; @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; - private final EmployeeRepository employeeRepository; - private final EmployeeRoleRepository employeeRoleRepository; - private final TokenRepository tokenRepository; + private final UserDetailsService userDetailsService; + private final AuthCookieService authCookieService; @Override protected void doFilterInternal( - @NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain ) throws ServletException, IOException { - // 인증 API는 필터링 제외 - if (request.getServletPath().contains("/api/v1/auth")) { + if (SecurityContextHolder.getContext().getAuthentication() != null) { filterChain.doFilter(request, response); return; } - final String authHeader = request.getHeader("Authorization"); - final String jwt; + String jwt = resolveToken(request); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { + if (jwt == null) { filterChain.doFilter(request, response); return; } - jwt = authHeader.substring(7); - final String empUuidStr = jwtService.extractUsername(jwt); + String username; + try { + username = jwtService.extractUsername(jwt); + } catch (Exception e) { + filterChain.doFilter(request, response); + return; + } - if (empUuidStr != null && SecurityContextHolder.getContext().getAuthentication() == null) { - UUID empUuid = UUID.fromString(empUuidStr); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); - // Employee 조회 - Employee employee = employeeRepository.findByEmpUuid(empUuid) - .orElseThrow(() -> new RuntimeException("Employee not found")); - // Role 조회 - List roles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); - EmployeeDetails employeeDetails = new EmployeeDetails(employee, roles); + if (!jwtService.isTokenValid(jwt, userDetails)) { + filterChain.doFilter(request, response); + return; + } - // DB 토큰 검증 - boolean isTokenValid = tokenRepository.findByToken(jwt) - .map(t -> !t.isExpired() && !t.isRevoked()) - .orElse(false); - - // JWT 유효성 검증 + DB 토큰 검증 - if (jwtService.isTokenValid(jwt, employee) && isTokenValid) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - employee.getEmpLoginId(), // principal → loginId - null, - employeeDetails.getAuthorities() - ); - authToken.setDetails( - new WebAuthenticationDetailsSource().buildDetails(request) + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() ); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } + + /** + * JWT 추출 우선순위: + * 1. Authorization Header + * 2. HttpOnly Cookie + */ + private String resolveToken(HttpServletRequest request) { + + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + + Optional cookieToken = authCookieService.extractJwt(request); + return cookieToken.orElse(null); + } } diff --git a/src/main/java/com/goi/erp/config/JwtService.java b/src/main/java/com/goi/erp/config/JwtService.java index 5566bf5..1db3341 100644 --- a/src/main/java/com/goi/erp/config/JwtService.java +++ b/src/main/java/com/goi/erp/config/JwtService.java @@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; + import java.security.Key; import java.util.Date; import java.util.HashMap; @@ -13,6 +14,7 @@ import java.util.Map; import java.util.function.Function; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import com.goi.erp.employee.Employee; @@ -20,7 +22,7 @@ import com.goi.erp.employee.Employee; @Service public class JwtService { - @Value("${application.security.jwt.secret-key}") + @Value("${application.security.jwt.secret-key}") private String secretKey; @Value("${application.security.jwt.expiration}") @@ -28,108 +30,85 @@ public class JwtService { @Value("${application.security.jwt.refresh-token.expiration}") private long refreshExpiration; - - // =================== 기존 UserDetails용 =================== - public String extractUsername(String token) { - return extractClaim(token, Claims::getSubject); - } - public T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaims(token); - return claimsResolver.apply(claims); - } + // =================== 공통 =================== - public String generateToken(Employee employee, List roles, List permissions) { - Map extraClaims = new HashMap<>(); - extraClaims.put("roles", roles); - - // Admin 계정 여부 확인 - boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin")); + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } - if (isAdmin) { - // Admin이면 permissions를 ALL로 단순화 - extraClaims.put("permissions", List.of("ALL")); - } else { - // 일반 계정이면 상세 권한 넣기 - extraClaims.put("permissions", permissions); - } - - // 직원 이름 추가 - extraClaims.put("firstName", employee.getEmpFirstName()); - extraClaims.put("lastName", employee.getEmpLastName()); - extraClaims.put("loginId", employee.getEmpLoginId()); - - return buildToken(extraClaims, employee.getEmpUuid().toString(), jwtExpiration); - } - - public String generateSystemToken(String clientId, java.util.List permissions) { - Map extraClaims = new HashMap<>(); - extraClaims.put("permissions", permissions); - extraClaims.put("loginId", clientId); - return buildToken(extraClaims, clientId, jwtExpiration); - } + public T extractClaim(String token, Function resolver) { + return resolver.apply(extractAllClaims(token)); + } + // =================== Token 생성 =================== - public String generateRefreshToken(Employee employee, List roles, List permissions) { - Map extraClaims = new HashMap<>(); - extraClaims.put("roles", roles); - // Admin 계정 여부 확인 - boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin")); + public String generateToken(Employee employee, List roles, List permissions) { + return buildEmployeeToken(employee, roles, permissions, jwtExpiration); + } - if (isAdmin) { - // Admin이면 permissions를 ALL로 단순화 - extraClaims.put("permissions", List.of("ALL")); - } else { - // 일반 계정이면 상세 권한 넣기 - extraClaims.put("permissions", permissions); - } - - // 직원 이름 추가 - extraClaims.put("firstName", employee.getEmpFirstName()); - extraClaims.put("lastName", employee.getEmpLastName()); - extraClaims.put("loginId", employee.getEmpLoginId()); - - return buildToken(extraClaims, employee.getEmpUuid().toString(), refreshExpiration); - } + public String generateRefreshToken(Employee employee, List roles, List permissions) { + return buildEmployeeToken(employee, roles, permissions, refreshExpiration); + } - private String buildToken(Map extraClaims, String subject, long expiration) { - return Jwts.builder().setClaims(extraClaims).setSubject(subject) - .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(getSignInKey(), SignatureAlgorithm.HS256).compact(); - } + private String buildEmployeeToken( + Employee employee, + List roles, + List permissions, + long expiration + ) { + Map claims = new HashMap<>(); + claims.put("roles", roles); - public boolean isTokenValid(String token, Employee employee) { - final String username = extractUsername(token); - return (username.equals(employee.getEmpUuid().toString())) && !isTokenExpired(token); - } + boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin")); + claims.put("permissions", isAdmin ? List.of("ALL") : permissions); - private boolean isTokenExpired(String token) { - return extractExpiration(token).before(new Date()); - } + claims.put("empUuid", employee.getEmpUuid().toString()); + claims.put("firstName", employee.getEmpFirstName()); + claims.put("lastName", employee.getEmpLastName()); - private Date extractExpiration(String token) { - return extractClaim(token, Claims::getExpiration); - } + // 🔥 subject는 loginId + return buildToken(claims, employee.getEmpLoginId(), expiration); + } - private Claims extractAllClaims(String token) { - return Jwts.parserBuilder().setSigningKey(getSignInKey()).build().parseClaimsJws(token).getBody(); - } + public String generateSystemToken(String clientId, List permissions) { + Map claims = new HashMap<>(); + claims.put("permissions", permissions); + return buildToken(claims, clientId, jwtExpiration); + } - private Key getSignInKey() { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - return Keys.hmacShaKeyFor(keyBytes); - } - - public static void main(String[] args) { - JwtService jwtService = new JwtService(); - jwtService.secretKey = ""; - - String token = ""; + // =================== Validation =================== - // user 정보 - Claims claims = jwtService.extractAllClaims(token); + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return username.equals(userDetails.getUsername()) && !isTokenExpired(token); + } - System.out.println("Claims: " + claims); - } + // =================== 내부 =================== + + private String buildToken(Map claims, String subject, long expiration) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + private boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new Date()); + } + + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Key getSignInKey() { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + } } diff --git a/src/main/java/com/goi/erp/config/LogoutService.java b/src/main/java/com/goi/erp/config/LogoutService.java index 7115381..ce49f84 100644 --- a/src/main/java/com/goi/erp/config/LogoutService.java +++ b/src/main/java/com/goi/erp/config/LogoutService.java @@ -7,34 +7,42 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.goi.erp.token.TokenRepository; @Service @RequiredArgsConstructor +@Transactional public class LogoutService implements LogoutHandler { - private final TokenRepository tokenRepository; + private final TokenRepository tokenRepository; - @Override - public void logout( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication - ) { - final String authHeader = request.getHeader("Authorization"); - final String jwt; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - return; + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + + // 로그아웃 한 번에 ACCESS + REFRESH 전부 revoke + String tokenValue = null; + + if (request.getCookies() != null) { + for (var cookie : request.getCookies()) { + if ("AUTH_TOKEN".equals(cookie.getName()) + || "REFRESH_TOKEN".equals(cookie.getName())) { + tokenValue = cookie.getValue(); + break; + } + } + } + + if (tokenValue == null) return; + + var tokenEntity = tokenRepository.findByToken(tokenValue).orElse(null); + if (tokenEntity == null) return; + + tokenRepository.revokeAllValidTokensByEmployee( + tokenEntity.getEmployee().getEmpId() + ); + + SecurityContextHolder.clearContext(); } - jwt = authHeader.substring(7); - var storedToken = tokenRepository.findByToken(jwt) - .orElse(null); - if (storedToken != null) { - storedToken.setExpired(true); - storedToken.setRevoked(true); - tokenRepository.save(storedToken); - SecurityContextHolder.clearContext(); - } - } } diff --git a/src/main/java/com/goi/erp/config/SecurityConfiguration.java b/src/main/java/com/goi/erp/config/SecurityConfiguration.java index 6b0bcd4..3181b10 100644 --- a/src/main/java/com/goi/erp/config/SecurityConfiguration.java +++ b/src/main/java/com/goi/erp/config/SecurityConfiguration.java @@ -3,7 +3,9 @@ package com.goi.erp.config; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -12,23 +14,28 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import static com.goi.erp.user.Permission.ADMIN_CREATE; -import static com.goi.erp.user.Permission.ADMIN_DELETE; -import static com.goi.erp.user.Permission.ADMIN_READ; -import static com.goi.erp.user.Permission.ADMIN_UPDATE; -import static com.goi.erp.user.Permission.MANAGER_CREATE; -import static com.goi.erp.user.Permission.MANAGER_DELETE; -import static com.goi.erp.user.Permission.MANAGER_READ; -import static com.goi.erp.user.Permission.MANAGER_UPDATE; -import static com.goi.erp.user.Role.ADMIN; -import static com.goi.erp.user.Role.MANAGER; -import static org.springframework.http.HttpMethod.DELETE; -import static org.springframework.http.HttpMethod.GET; -import static org.springframework.http.HttpMethod.POST; -import static org.springframework.http.HttpMethod.PUT; +//import static com.goi.erp.user.Permission.ADMIN_CREATE; +//import static com.goi.erp.user.Permission.ADMIN_DELETE; +//import static com.goi.erp.user.Permission.ADMIN_READ; +//import static com.goi.erp.user.Permission.ADMIN_UPDATE; +//import static com.goi.erp.user.Permission.MANAGER_CREATE; +//import static com.goi.erp.user.Permission.MANAGER_DELETE; +//import static com.goi.erp.user.Permission.MANAGER_READ; +//import static com.goi.erp.user.Permission.MANAGER_UPDATE; +//import static com.goi.erp.user.Role.ADMIN; +//import static com.goi.erp.user.Role.MANAGER; +//import static org.springframework.http.HttpMethod.DELETE; +//import static org.springframework.http.HttpMethod.GET; +//import static org.springframework.http.HttpMethod.POST; +//import static org.springframework.http.HttpMethod.PUT; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; +import java.util.List; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -50,19 +57,38 @@ public class SecurityConfiguration { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; private final LogoutHandler logoutHandler; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of("http://localhost:5173")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-TOKEN")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(req -> - req.requestMatchers(WHITE_LIST_URL) + req.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/auth/login", "/auth/token/refresh", "/auth/login/system").permitAll() + .requestMatchers("/auth/logout").authenticated() + .requestMatchers(WHITE_LIST_URL) .permitAll() - .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) - .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) - .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) - .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) - .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) +// .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) +// .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) +// .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) +// .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) +// .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) .anyRequest() .authenticated() ) @@ -70,7 +96,7 @@ public class SecurityConfiguration { .authenticationProvider(authenticationProvider) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .logout(logout -> - logout.logoutUrl("/api/v1/auth/logout") + logout.logoutUrl("/auth/logout") .addLogoutHandler(logoutHandler) .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) ) diff --git a/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java b/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java index 573f498..31a5308 100644 --- a/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java +++ b/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java @@ -10,10 +10,11 @@ import java.util.List; public interface EmployeeRoleRepository extends JpaRepository { @Query(""" - SELECT er - FROM EmployeeRole er - WHERE er.employee.id = :empId - AND er.emrRevokedAt IS NULL - """) - List findActiveRolesByEmployeeId(Long empId); + SELECT er + FROM EmployeeRole er + JOIN FETCH er.roleInfo + WHERE er.employee.id = :empId + AND er.emrRevokedAt IS NULL + """) + List findActiveRolesByEmployeeId(Long empId); } \ No newline at end of file diff --git a/src/main/java/com/goi/erp/token/TokenRepository.java b/src/main/java/com/goi/erp/token/TokenRepository.java index 0ebc347..e902792 100644 --- a/src/main/java/com/goi/erp/token/TokenRepository.java +++ b/src/main/java/com/goi/erp/token/TokenRepository.java @@ -2,6 +2,7 @@ package com.goi.erp.token; import com.goi.erp.employee.Employee; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import java.util.List; @@ -9,14 +10,26 @@ import java.util.Optional; public interface TokenRepository extends JpaRepository { - @Query(""" - select t from Token t - where t.employee.id = :employeeId - and (t.expired = false or t.revoked = false) - """) - List findAllValidTokenByEmployee(Long employeeId); + @Query(""" + select t from Token t + where t.employee.id = :employeeId + and t.expired = false + and t.revoked = false + """) + List findAllValidTokenByEmployee(Long employeeId); Optional findByToken(String token); List findByEmployee(Employee employee); + + @Modifying + @Query(""" + update Token t + set t.expired = true, t.revoked = true + where t.employee.id = :employeeId + and t.tokenType in ('ACCESS','REFRESH') + and t.expired = false + and t.revoked = false + """) + int revokeAllValidTokensByEmployee(Long employeeId); } diff --git a/src/main/java/com/goi/erp/token/TokenType.java b/src/main/java/com/goi/erp/token/TokenType.java index d062f85..457b686 100644 --- a/src/main/java/com/goi/erp/token/TokenType.java +++ b/src/main/java/com/goi/erp/token/TokenType.java @@ -1,5 +1,7 @@ package com.goi.erp.token; public enum TokenType { + ACCESS, + REFRESH, BEARER } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5538a98..25eb34c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,7 +27,11 @@ application: secret: ${OPR_SYSTEM_CLIENT_SECRET} permissions: - "H:R:A" - +auth: + cookie: + secure: false # prod: true / local: false + same-site: Lax # 필요 시 None (→ Secure 필수) + path: / server: port: 8080 servlet: