[Auth] Changed JWT into Cookie

This commit is contained in:
Hyojin Ahn 2026-01-27 13:46:28 -05:00
parent 78d836dfa3
commit 68233402fc
13 changed files with 548 additions and 360 deletions

View File

@ -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<String> 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<String> 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<String> 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());
}
}

View File

@ -4,10 +4,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException; import java.io.IOException;
@ -16,27 +13,37 @@ import java.io.IOException;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthenticationController { public class AuthenticationController {
private final AuthenticationService service; private final AuthenticationService authenticationService;
// @PostMapping("/register") // ===================== 로그인 =====================
// public ResponseEntity<AuthenticationResponse> register(
// @RequestBody RegisterRequest request
// ) {
// return ResponseEntity.ok(service.register(request));
// }
@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> authenticate(@RequestBody AuthenticationRequest request) {
return ResponseEntity.ok(service.authenticate(request));
}
@PostMapping("/refresh-token") @PostMapping("/login")
public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { public ResponseEntity<AuthenticationResponse> authenticate(
service.refreshToken(request, response); @RequestBody AuthenticationRequest request,
} HttpServletResponse response
) {
AuthenticationResponse authResponse = authenticationService.authenticate(request, response);
return ResponseEntity.ok(authResponse);
}
@PostMapping("/authenticate/system") // ===================== Refresh Token =====================
public AuthenticationResponse authenticateSystem(@RequestBody SystemAuthenticationRequestDto request) {
return service.authenticateSystem(request);
}
@PostMapping("/token/refresh")
public void refreshToken(
HttpServletRequest request,
HttpServletResponse response
) throws IOException {
authenticationService.refreshToken(request, response);
}
// ===================== System Token =====================
@PostMapping("/login/system")
public ResponseEntity<SystemAuthenticationResponseDto> authenticateSystem(
@RequestBody SystemAuthenticationRequestDto request
) {
return ResponseEntity.ok(
authenticationService.authenticateSystem(request)
);
}
} }

View File

@ -1,19 +1,23 @@
package com.goi.erp.auth; package com.goi.erp.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List;
@Data @Data
@Builder @Builder
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class AuthenticationResponse { public class AuthenticationResponse {
@JsonProperty("access_token") private boolean authenticated;
private String accessToken;
@JsonProperty("refresh_token") private String loginId;
private String refreshToken; private String firstName;
private String lastName;
private List<String> roles;
} }

View File

@ -1,190 +1,204 @@
package com.goi.erp.auth; package com.goi.erp.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.erp.common.exception.InvalidPasswordException; import com.goi.erp.common.exception.InvalidPasswordException;
import com.goi.erp.common.exception.UserNotFoundException; import com.goi.erp.common.exception.UserNotFoundException;
import com.goi.erp.config.JwtService; import com.goi.erp.config.JwtService;
import com.goi.erp.config.SecuritySystemClientsProperties; 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.Employee;
import com.goi.erp.employee.EmployeeRepository; import com.goi.erp.employee.EmployeeRepository;
import com.goi.erp.employee.EmployeeRole; import com.goi.erp.employee.EmployeeRole;
import com.goi.erp.employee.EmployeeRoleRepository; import com.goi.erp.employee.EmployeeRoleRepository;
import com.goi.erp.role.RolePermissionRepository; 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.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; 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.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@Transactional
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthenticationService { public class AuthenticationService {
private final EmployeeRepository employeeRepository;
private final EmployeeRepository employeeRepository;
private final EmployeeRoleRepository employeeRoleRepository; private final EmployeeRoleRepository employeeRoleRepository;
private final RolePermissionRepository rolePermissionRepository;
private final TokenRepository tokenRepository; private final TokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RolePermissionRepository rolePermissionRepository;
private final JwtService jwtService; private final JwtService jwtService;
private final SecuritySystemClientsProperties systemClientsProperties; 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(
public AuthenticationResponse authenticate(AuthenticationRequest request) { AuthenticationRequest request,
// 1. Employee 조회 HttpServletResponse response
) {
Employee employee = employeeRepository.findByEmpLoginId(request.getEmpLoginId()) Employee employee = employeeRepository.findByEmpLoginId(request.getEmpLoginId())
.orElseThrow(() -> new UserNotFoundException("Employee not found")); .orElseThrow(() -> new UserNotFoundException("Employee not found"));
// 2. 비밀번호 검증
if (!passwordEncoder.matches(request.getEmpLoginPassword(), employee.getEmpLoginPassword())) { if (!passwordEncoder.matches(request.getEmpLoginPassword(), employee.getEmpLoginPassword())) {
throw new InvalidPasswordException("Invalid password"); throw new InvalidPasswordException("Invalid password");
} }
// 3. EmployeeRole 조회 Role 이름 리스트 생성 List<EmployeeRole> activeRoles =
List<EmployeeRole> activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
List<String> roles = activeRoles.stream() List<String> roles = extractRoles(activeRoles);
.map(er -> er.getRoleInfo().getRoleName()) List<String> permissions = extractPermissions(activeRoles);
.collect(Collectors.toList());
String accessToken = jwtService.generateToken(employee, roles, permissions);
// 4. Role Permission 조회
List<String> 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 refreshToken = jwtService.generateRefreshToken(employee, roles, permissions); String refreshToken = jwtService.generateRefreshToken(employee, roles, permissions);
// 기존 토큰 회수 토큰 저장
revokeAllEmployeeTokens(employee); 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() return AuthenticationResponse.builder()
.accessToken(jwtToken) .authenticated(true)
.refreshToken(refreshToken) .loginId(employee.getEmpLoginId())
.firstName(employee.getEmpFirstName())
.lastName(employee.getEmpLastName())
.roles(roles)
.build(); .build();
} }
// JWT 토큰 저장 // ===================== Refresh Token =====================
private void saveEmployeeToken(Employee employee, String jwtToken) {
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<EmployeeRole> activeRoles =
employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
List<String> roles = extractRoles(activeRoles);
List<String> 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<String> extractRoles(List<EmployeeRole> roles) {
return roles.stream()
.map(er -> er.getRoleInfo().getRoleName())
.collect(Collectors.toList());
}
private List<String> extractPermissions(List<EmployeeRole> 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() Token token = Token.builder()
.employee(employee) .employee(employee)
.token(jwtToken) .token(jwtToken)
.tokenType(TokenType.BEARER) .tokenType(tokenType)
.expired(false) .expired(false)
.revoked(false) .revoked(false)
.build(); .build();
tokenRepository.save(token); tokenRepository.save(token);
} }
// 기존 토큰 회수
private void revokeAllEmployeeTokens(Employee employee) { private void revokeAllEmployeeTokens(Employee employee) {
List<Token> validTokens = tokenRepository.findAllValidTokenByEmployee(employee.getEmpId()); tokenRepository.revokeAllValidTokensByEmployee(employee.getEmpId());
if (validTokens.isEmpty()) return;
validTokens.forEach(token -> {
token.setExpired(true);
token.setRevoked(true);
});
tokenRepository.saveAll(validTokens);
} }
// 리프레시 토큰 처리
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<EmployeeRole> activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
List<String> roles = activeRoles.stream()
.map(er -> er.getRoleInfo().getRoleName())
.collect(Collectors.toList());
// 4. Role Permission 조회
List<String> 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<String> 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();
}
} }

View File

@ -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;
}

View File

@ -1,91 +1,93 @@
package com.goi.erp.config; 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.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; 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.io.IOException;
import java.util.List; import java.util.Optional;
import java.util.UUID;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final EmployeeRepository employeeRepository; private final UserDetailsService userDetailsService;
private final EmployeeRoleRepository employeeRoleRepository; private final AuthCookieService authCookieService;
private final TokenRepository tokenRepository;
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
@NonNull HttpServletRequest request, HttpServletRequest request,
@NonNull HttpServletResponse response, HttpServletResponse response,
@NonNull FilterChain filterChain FilterChain filterChain
) throws ServletException, IOException { ) throws ServletException, IOException {
// 인증 API는 필터링 제외 if (SecurityContextHolder.getContext().getAuthentication() != null) {
if (request.getServletPath().contains("/api/v1/auth")) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
final String authHeader = request.getHeader("Authorization"); String jwt = resolveToken(request);
final String jwt;
if (authHeader == null || !authHeader.startsWith("Bearer ")) { if (jwt == null) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
jwt = authHeader.substring(7); String username;
final String empUuidStr = jwtService.extractUsername(jwt); try {
username = jwtService.extractUsername(jwt);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (empUuidStr != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UUID empUuid = UUID.fromString(empUuidStr);
// Employee 조회 if (!jwtService.isTokenValid(jwt, userDetails)) {
Employee employee = employeeRepository.findByEmpUuid(empUuid) filterChain.doFilter(request, response);
.orElseThrow(() -> new RuntimeException("Employee not found")); return;
// Role 조회 }
List<EmployeeRole> roles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
EmployeeDetails employeeDetails = new EmployeeDetails(employee, roles);
// DB 토큰 검증 UsernamePasswordAuthenticationToken authentication =
boolean isTokenValid = tokenRepository.findByToken(jwt) new UsernamePasswordAuthenticationToken(
.map(t -> !t.isExpired() && !t.isRevoked()) userDetails,
.orElse(false); null,
userDetails.getAuthorities()
// 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)
); );
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response); 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<String> cookieToken = authCookieService.extractJwt(request);
return cookieToken.orElse(null);
}
} }

View File

@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import java.security.Key; import java.security.Key;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
@ -13,6 +14,7 @@ import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.goi.erp.employee.Employee; import com.goi.erp.employee.Employee;
@ -20,7 +22,7 @@ import com.goi.erp.employee.Employee;
@Service @Service
public class JwtService { public class JwtService {
@Value("${application.security.jwt.secret-key}") @Value("${application.security.jwt.secret-key}")
private String secretKey; private String secretKey;
@Value("${application.security.jwt.expiration}") @Value("${application.security.jwt.expiration}")
@ -29,107 +31,84 @@ public class JwtService {
@Value("${application.security.jwt.refresh-token.expiration}") @Value("${application.security.jwt.refresh-token.expiration}")
private long refreshExpiration; private long refreshExpiration;
// =================== 기존 UserDetails용 =================== // =================== 공통 ===================
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { public String extractUsername(String token) {
final Claims claims = extractAllClaims(token); return extractClaim(token, Claims::getSubject);
return claimsResolver.apply(claims); }
}
public String generateToken(Employee employee, List<String> roles, List<String> permissions) { public <T> T extractClaim(String token, Function<Claims, T> resolver) {
Map<String, Object> extraClaims = new HashMap<>(); return resolver.apply(extractAllClaims(token));
extraClaims.put("roles", roles); }
// Admin 계정 여부 확인 // =================== Token 생성 ===================
boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin"));
if (isAdmin) { public String generateToken(Employee employee, List<String> roles, List<String> permissions) {
// Admin이면 permissions를 ALL로 단순화 return buildEmployeeToken(employee, roles, permissions, jwtExpiration);
extraClaims.put("permissions", List.of("ALL")); }
} else {
// 일반 계정이면 상세 권한 넣기
extraClaims.put("permissions", permissions);
}
// 직원 이름 추가 public String generateRefreshToken(Employee employee, List<String> roles, List<String> permissions) {
extraClaims.put("firstName", employee.getEmpFirstName()); return buildEmployeeToken(employee, roles, permissions, refreshExpiration);
extraClaims.put("lastName", employee.getEmpLastName()); }
extraClaims.put("loginId", employee.getEmpLoginId());
return buildToken(extraClaims, employee.getEmpUuid().toString(), jwtExpiration); private String buildEmployeeToken(
} Employee employee,
List<String> roles,
List<String> permissions,
long expiration
) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", roles);
public String generateSystemToken(String clientId, java.util.List<String> permissions) { boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin"));
Map<String, Object> extraClaims = new HashMap<>(); claims.put("permissions", isAdmin ? List.of("ALL") : permissions);
extraClaims.put("permissions", permissions);
extraClaims.put("loginId", clientId);
return buildToken(extraClaims, clientId, jwtExpiration);
}
claims.put("empUuid", employee.getEmpUuid().toString());
claims.put("firstName", employee.getEmpFirstName());
claims.put("lastName", employee.getEmpLastName());
public String generateRefreshToken(Employee employee, List<String> roles, List<String> permissions) { // 🔥 subject는 loginId
Map<String, Object> extraClaims = new HashMap<>(); return buildToken(claims, employee.getEmpLoginId(), expiration);
extraClaims.put("roles", roles); }
// Admin 계정 여부 확인
boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin"));
if (isAdmin) { public String generateSystemToken(String clientId, List<String> permissions) {
// Admin이면 permissions를 ALL로 단순화 Map<String, Object> claims = new HashMap<>();
extraClaims.put("permissions", List.of("ALL")); claims.put("permissions", permissions);
} else { return buildToken(claims, clientId, jwtExpiration);
// 일반 계정이면 상세 권한 넣기 }
extraClaims.put("permissions", permissions);
}
// 직원 이름 추가 // =================== Validation ===================
extraClaims.put("firstName", employee.getEmpFirstName());
extraClaims.put("lastName", employee.getEmpLastName());
extraClaims.put("loginId", employee.getEmpLoginId());
return buildToken(extraClaims, employee.getEmpUuid().toString(), refreshExpiration); public boolean isTokenValid(String token, UserDetails userDetails) {
} final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private String buildToken(Map<String, Object> 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();
}
public boolean isTokenValid(String token, Employee employee) { private String buildToken(Map<String, Object> claims, String subject, long expiration) {
final String username = extractUsername(token); return Jwts.builder()
return (username.equals(employee.getEmpUuid().toString())) && !isTokenExpired(token); .setClaims(claims)
} .setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
private boolean isTokenExpired(String token) { private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date()); return extractClaim(token, Claims::getExpiration).before(new Date());
} }
private Date extractExpiration(String token) { private Claims extractAllClaims(String token) {
return extractClaim(token, Claims::getExpiration); return Jwts.parserBuilder()
} .setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Claims extractAllClaims(String token) { private Key getSignInKey() {
return Jwts.parserBuilder().setSigningKey(getSignInKey()).build().parseClaimsJws(token).getBody(); return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
} }
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 = "";
// user 정보
Claims claims = jwtService.extractAllClaims(token);
System.out.println("Claims: " + claims);
}
} }

View File

@ -7,34 +7,42 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.goi.erp.token.TokenRepository; import com.goi.erp.token.TokenRepository;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional
public class LogoutService implements LogoutHandler { public class LogoutService implements LogoutHandler {
private final TokenRepository tokenRepository; private final TokenRepository tokenRepository;
@Override @Override
public void logout( public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
HttpServletRequest request,
HttpServletResponse response, // 로그아웃 번에 ACCESS + REFRESH 전부 revoke
Authentication authentication String tokenValue = null;
) {
final String authHeader = request.getHeader("Authorization"); if (request.getCookies() != null) {
final String jwt; for (var cookie : request.getCookies()) {
if (authHeader == null ||!authHeader.startsWith("Bearer ")) { if ("AUTH_TOKEN".equals(cookie.getName())
return; || "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();
}
}
} }

View File

@ -3,7 +3,9 @@ package com.goi.erp.config;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationProvider; 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.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -12,23 +14,28 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler; 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_CREATE;
import static com.goi.erp.user.Permission.ADMIN_DELETE; //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_READ;
import static com.goi.erp.user.Permission.ADMIN_UPDATE; //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_CREATE;
import static com.goi.erp.user.Permission.MANAGER_DELETE; //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_READ;
import static com.goi.erp.user.Permission.MANAGER_UPDATE; //import static com.goi.erp.user.Permission.MANAGER_UPDATE;
import static com.goi.erp.user.Role.ADMIN; //import static com.goi.erp.user.Role.ADMIN;
import static com.goi.erp.user.Role.MANAGER; //import static com.goi.erp.user.Role.MANAGER;
import static org.springframework.http.HttpMethod.DELETE; //import static org.springframework.http.HttpMethod.DELETE;
import static org.springframework.http.HttpMethod.GET; //import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.POST; //import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT; //import static org.springframework.http.HttpMethod.PUT;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
import java.util.List;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
@ -51,18 +58,37 @@ public class SecurityConfiguration {
private final AuthenticationProvider authenticationProvider; private final AuthenticationProvider authenticationProvider;
private final LogoutHandler logoutHandler; 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 @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(req -> .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() .permitAll()
.requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) // .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name())
.requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.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(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name())
.requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.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(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name())
.anyRequest() .anyRequest()
.authenticated() .authenticated()
) )
@ -70,7 +96,7 @@ public class SecurityConfiguration {
.authenticationProvider(authenticationProvider) .authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.logout(logout -> .logout(logout ->
logout.logoutUrl("/api/v1/auth/logout") logout.logoutUrl("/auth/logout")
.addLogoutHandler(logoutHandler) .addLogoutHandler(logoutHandler)
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext())
) )

View File

@ -10,10 +10,11 @@ import java.util.List;
public interface EmployeeRoleRepository extends JpaRepository<EmployeeRole, Integer> { public interface EmployeeRoleRepository extends JpaRepository<EmployeeRole, Integer> {
@Query(""" @Query("""
SELECT er SELECT er
FROM EmployeeRole er FROM EmployeeRole er
WHERE er.employee.id = :empId JOIN FETCH er.roleInfo
AND er.emrRevokedAt IS NULL WHERE er.employee.id = :empId
""") AND er.emrRevokedAt IS NULL
List<EmployeeRole> findActiveRolesByEmployeeId(Long empId); """)
List<EmployeeRole> findActiveRolesByEmployeeId(Long empId);
} }

View File

@ -2,6 +2,7 @@ package com.goi.erp.token;
import com.goi.erp.employee.Employee; import com.goi.erp.employee.Employee;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import java.util.List; import java.util.List;
@ -9,14 +10,26 @@ import java.util.Optional;
public interface TokenRepository extends JpaRepository<Token, Integer> { public interface TokenRepository extends JpaRepository<Token, Integer> {
@Query(""" @Query("""
select t from Token t select t from Token t
where t.employee.id = :employeeId where t.employee.id = :employeeId
and (t.expired = false or t.revoked = false) and t.expired = false
""") and t.revoked = false
List<Token> findAllValidTokenByEmployee(Long employeeId); """)
List<Token> findAllValidTokenByEmployee(Long employeeId);
Optional<Token> findByToken(String token); Optional<Token> findByToken(String token);
List<Token> findByEmployee(Employee employee); List<Token> 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);
} }

View File

@ -1,5 +1,7 @@
package com.goi.erp.token; package com.goi.erp.token;
public enum TokenType { public enum TokenType {
ACCESS,
REFRESH,
BEARER BEARER
} }

View File

@ -27,7 +27,11 @@ application:
secret: ${OPR_SYSTEM_CLIENT_SECRET} secret: ${OPR_SYSTEM_CLIENT_SECRET}
permissions: permissions:
- "H:R:A" - "H:R:A"
auth:
cookie:
secure: false # prod: true / local: false
same-site: Lax # 필요 시 None (→ Secure 필수)
path: /
server: server:
port: 8080 port: 8080
servlet: servlet: