[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 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<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")
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<AuthenticationResponse> 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<SystemAuthenticationResponseDto> authenticateSystem(
@RequestBody SystemAuthenticationRequestDto request
) {
return ResponseEntity.ok(
authenticationService.authenticateSystem(request)
);
}
}

View File

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

View File

@ -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<EmployeeRole> activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
List<EmployeeRole> activeRoles =
employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
List<String> roles = activeRoles.stream()
.map(er -> er.getRoleInfo().getRoleName())
.collect(Collectors.toList());
List<String> roles = extractRoles(activeRoles);
List<String> permissions = extractPermissions(activeRoles);
// 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 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<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()
.employee(employee)
.token(jwtToken)
.tokenType(TokenType.BEARER)
.tokenType(tokenType)
.expired(false)
.revoked(false)
.build();
tokenRepository.save(token);
}
// 기존 토큰 회수
private void revokeAllEmployeeTokens(Employee employee) {
List<Token> 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<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;
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<EmployeeRole> 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<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.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> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
// =================== 공통 ===================
public String generateToken(Employee employee, List<String> roles, List<String> permissions) {
Map<String, Object> 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<String> permissions) {
Map<String, Object> extraClaims = new HashMap<>();
extraClaims.put("permissions", permissions);
extraClaims.put("loginId", clientId);
return buildToken(extraClaims, clientId, jwtExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> resolver) {
return resolver.apply(extractAllClaims(token));
}
// =================== Token 생성 ===================
public String generateRefreshToken(Employee employee, List<String> roles, List<String> permissions) {
Map<String, Object> extraClaims = new HashMap<>();
extraClaims.put("roles", roles);
// Admin 계정 여부 확인
boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin"));
public String generateToken(Employee employee, List<String> roles, List<String> 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<String> roles, List<String> permissions) {
return buildEmployeeToken(employee, roles, permissions, refreshExpiration);
}
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();
}
private String buildEmployeeToken(
Employee employee,
List<String> roles,
List<String> permissions,
long expiration
) {
Map<String, Object> 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<String> permissions) {
Map<String, Object> 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<String, Object> 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));
}
}

View File

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

View File

@ -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())
)

View File

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

View File

@ -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<Token, Integer> {
@Query("""
select t from Token t
where t.employee.id = :employeeId
and (t.expired = false or t.revoked = false)
""")
List<Token> findAllValidTokenByEmployee(Long employeeId);
@Query("""
select t from Token t
where t.employee.id = :employeeId
and t.expired = false
and t.revoked = false
""")
List<Token> findAllValidTokenByEmployee(Long employeeId);
Optional<Token> findByToken(String token);
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;
public enum TokenType {
ACCESS,
REFRESH,
BEARER
}

View File

@ -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: