This commit is contained in:
djeon
2025-10-23 14:55:33 +09:00
parent 41d57e7399
commit 98ede67f62
109 changed files with 8633 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
bootJar {
archiveFileName = 'user.jar'
}
dependencies {
// LDAP
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
implementation 'org.springframework.ldap:spring-ldap-core'
}
@@ -0,0 +1,18 @@
package com.unicorn.hgzero.user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
/**
* User Service Application
* 사용자 인증 서비스 메인 클래스
*/
@SpringBootApplication
@ComponentScan(basePackages = {"com.unicorn.hgzero.user", "com.unicorn.hgzero.common"})
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.java, args);
}
}
@@ -0,0 +1,85 @@
package com.unicorn.hgzero.user.config;
import com.unicorn.hgzero.user.config.jwt.JwtAuthenticationFilter;
import com.unicorn.hgzero.user.config.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정
* JWT 기반 인증 및 API 보안 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/v1/auth/login").permitAll()
// Actuator endpoints
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check
.requestMatchers("/health").permitAll()
// All other requests require authentication
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
// 허용할 HTTP 메소드
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
// 자격 증명 허용
configuration.setAllowCredentials(true);
// Pre-flight 요청 캐시 시간
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,63 @@
package com.unicorn.hgzero.user.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger/OpenAPI 설정
* User Service API 문서화를 위한 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8080")
.description("Local Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.description("Custom Server")
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("http")
.description("Protocol (http or https)")
.addEnumItem("http")
.addEnumItem("https"))
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("localhost")
.description("Server host"))
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8080")
.description("Server port"))))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
}
private Info apiInfo() {
return new Info()
.title("User Service API")
.description("사용자 인증 및 JWT 토큰 관리 API")
.version("1.0.0")
.contact(new Contact()
.name("HGZero Development Team")
.email("dev@hgzero.com"));
}
private SecurityScheme createAPIKeyScheme() {
return new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.bearerFormat("JWT")
.scheme("bearer");
}
}
@@ -0,0 +1,86 @@
package com.unicorn.hgzero.user.config.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
/**
* JWT 인증 필터
* HTTP 요청에서 JWT 토큰을 추출하여 인증을 수행
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserId(token);
String username = null;
String authority = null;
try {
username = jwtTokenProvider.getUsername(token);
} catch (Exception e) {
log.debug("JWT에 username 클레임이 없음: {}", e.getMessage());
}
try {
authority = jwtTokenProvider.getAuthority(token);
} catch (Exception e) {
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
}
if (StringUtils.hasText(userId)) {
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
UserPrincipal userPrincipal = UserPrincipal.builder()
.userId(userId)
.username(username != null ? username : "unknown")
.authority(authority != null ? authority : "USER")
.build();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userPrincipal,
null,
Collections.singletonList(new SimpleGrantedAuthority(authority != null ? authority : "USER"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
}
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/actuator") ||
path.startsWith("/swagger-ui") ||
path.startsWith("/v3/api-docs") ||
path.equals("/health");
}
}
@@ -0,0 +1,133 @@
package com.unicorn.hgzero.user.config.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 토큰 제공자
* JWT 토큰의 생성, 검증, 파싱을 담당
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long tokenValidityInMilliseconds;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity:3600}") long tokenValidityInSeconds) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
/**
* HTTP 요청에서 JWT 토큰 추출
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
/**
* JWT 토큰 유효성 검증
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.debug("Invalid JWT signature: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.debug("Expired JWT token: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.debug("Unsupported JWT token: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.debug("JWT token compact of handler are invalid: {}", e.getMessage());
}
return false;
}
/**
* JWT 토큰에서 사용자 ID 추출
*/
public String getUserId(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
/**
* JWT 토큰에서 사용자명 추출
*/
public String getUsername(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("username", String.class);
}
/**
* JWT 토큰에서 권한 정보 추출
*/
public String getAuthority(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("authority", String.class);
}
/**
* 토큰 만료 시간 확인
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
/**
* 토큰에서 만료 시간 추출
*/
public Date getExpirationDate(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getExpiration();
}
}
@@ -0,0 +1,51 @@
package com.unicorn.hgzero.user.config.jwt;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 인증된 사용자 정보
* JWT 토큰에서 추출된 사용자 정보를 담는 Principal 객체
*/
@Getter
@Builder
@RequiredArgsConstructor
public class UserPrincipal {
/**
* 사용자 고유 ID
*/
private final String userId;
/**
* 사용자명
*/
private final String username;
/**
* 사용자 권한
*/
private final String authority;
/**
* 사용자 ID 반환 (별칭)
*/
public String getName() {
return userId;
}
/**
* 관리자 권한 여부 확인
*/
public boolean isAdmin() {
return "ADMIN".equals(authority);
}
/**
* 일반 사용자 권한 여부 확인
*/
public boolean isUser() {
return "USER".equals(authority) || authority == null;
}
}
@@ -0,0 +1,127 @@
package com.unicorn.hgzero.user.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.user.config.jwt.UserPrincipal;
import com.unicorn.hgzero.user.dto.*;
import com.unicorn.hgzero.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/**
* 사용자 인증 Controller
* 로그인, 토큰 갱신, 로그아웃, 토큰 검증 API 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "User Authentication", description = "사용자 인증 API")
public class UserController {
private final UserService userService;
/**
* 사용자 로그인
* LDAP 인증 수행 후 JWT 토큰 발급
*
* @param request 로그인 요청 정보 (userId, password)
* @return 로그인 응답 (Access Token, Refresh Token, 사용자 정보)
*/
@PostMapping("/login")
@Operation(
summary = "사용자 로그인",
description = "LDAP 인증 수행 후 JWT Access Token과 Refresh Token을 발급합니다."
)
public ResponseEntity<ApiResponse<LoginResponse>> login(
@Valid @RequestBody LoginRequest request) {
log.info("로그인 요청: userId={}", request.getUserId());
LoginResponse response = userService.login(request);
return ResponseEntity.ok(ApiResponse.success("로그인 성공", response));
}
/**
* Access Token 갱신
* Refresh Token을 사용하여 새로운 Access Token 발급
*
* @param request Refresh Token 요청 정보
* @return 새로운 Access Token
*/
@PostMapping("/refresh")
@Operation(
summary = "Access Token 갱신",
description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다."
)
public ResponseEntity<ApiResponse<RefreshTokenResponse>> refresh(
@Valid @RequestBody RefreshTokenRequest request) {
log.info("토큰 갱신 요청");
RefreshTokenResponse response = userService.refresh(request);
return ResponseEntity.ok(ApiResponse.success("토큰 갱신 성공", response));
}
/**
* 로그아웃
* Refresh Token 삭제
*
* @param request 로그아웃 요청 정보 (선택적 Refresh Token)
* @param userPrincipal 인증된 사용자 정보
* @return 로그아웃 성공 메시지
*/
@PostMapping("/logout")
@Operation(
summary = "로그아웃",
description = "Refresh Token을 삭제하고 로그아웃합니다."
)
public ResponseEntity<ApiResponse<Void>> logout(
@RequestBody(required = false) LogoutRequest request,
@AuthenticationPrincipal UserPrincipal userPrincipal) {
log.info("로그아웃 요청: userId={}", userPrincipal.getUserId());
userService.logout(request, userPrincipal.getUserId());
return ResponseEntity.ok(ApiResponse.success("로그아웃 성공", null));
}
/**
* Access Token 검증
* 토큰 유효성 검증 및 사용자 정보 반환
*
* @param authorization Authorization 헤더 (Bearer {token})
* @return 토큰 검증 결과 및 사용자 정보
*/
@GetMapping("/validate")
@Operation(
summary = "Access Token 검증",
description = "Access Token의 유효성을 검증하고 사용자 정보를 반환합니다."
)
public ResponseEntity<ApiResponse<TokenValidateResponse>> validate(
@Parameter(description = "Bearer {token}", required = true)
@RequestHeader("Authorization") String authorization) {
log.info("토큰 검증 요청");
// Bearer 제거
String token = authorization.replace("Bearer ", "");
TokenValidateResponse response = userService.validateToken(token);
if (response.getValid()) {
return ResponseEntity.ok(ApiResponse.success("토큰 검증 성공", response));
} else {
return ResponseEntity.ok(ApiResponse.success("토큰 검증 실패", response));
}
}
}
@@ -0,0 +1,104 @@
package com.unicorn.hgzero.user.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 사용자 도메인 모델
* 사용자 인증 및 계정 정보를 표현
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String username;
/**
* 이메일
*/
private String email;
/**
* 권한 (USER, ADMIN)
*/
private String authority;
/**
* 계정 잠금 여부
*/
private Boolean locked;
/**
* 로그인 실패 횟수
*/
private Integer failedLoginAttempts;
/**
* 마지막 로그인 일시
*/
private LocalDateTime lastLoginAt;
/**
* 계정 잠금 일시
*/
private LocalDateTime lockedAt;
/**
* 계정 잠금 해제
*/
public void unlock() {
this.locked = false;
this.failedLoginAttempts = 0;
this.lockedAt = null;
}
/**
* 로그인 실패 기록
* 5회 실패 시 계정 잠금
*/
public void recordLoginFailure() {
this.failedLoginAttempts++;
if (this.failedLoginAttempts >= 5) {
this.locked = true;
this.lockedAt = LocalDateTime.now();
}
}
/**
* 로그인 성공 기록
*/
public void recordLoginSuccess() {
this.failedLoginAttempts = 0;
this.lastLoginAt = LocalDateTime.now();
}
/**
* 계정 잠금 여부 확인
* 30분 경과 시 자동 해제
*/
public boolean isLocked() {
if (this.locked && this.lockedAt != null) {
// 30분 경과 시 자동 해제
if (LocalDateTime.now().isAfter(this.lockedAt.plusMinutes(30))) {
this.unlock();
return false;
}
return true;
}
return false;
}
}
@@ -0,0 +1,29 @@
package com.unicorn.hgzero.user.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그인 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
/**
* 사용자 ID
*/
@NotBlank(message = "사용자 ID는 필수입니다.")
private String userId;
/**
* 비밀번호
*/
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
}
@@ -0,0 +1,76 @@
package com.unicorn.hgzero.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그인 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
/**
* Access Token
*/
private String accessToken;
/**
* Refresh Token
*/
private String refreshToken;
/**
* 토큰 타입 (Bearer)
*/
@Builder.Default
private String tokenType = "Bearer";
/**
* Access Token 유효 기간 (초)
*/
private Long accessTokenValidity;
/**
* Refresh Token 유효 기간 (초)
*/
private Long refreshTokenValidity;
/**
* 사용자 정보
*/
private UserInfo user;
/**
* 사용자 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class UserInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String username;
/**
* 이메일
*/
private String email;
/**
* 권한
*/
private String authority;
}
}
@@ -0,0 +1,22 @@
package com.unicorn.hgzero.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그아웃 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LogoutRequest {
/**
* Refresh Token (선택사항)
* 제공되면 해당 Refresh Token을 명시적으로 삭제
*/
private String refreshToken;
}
@@ -0,0 +1,23 @@
package com.unicorn.hgzero.user.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Refresh Token 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
/**
* Refresh Token
*/
@NotBlank(message = "Refresh Token은 필수입니다.")
private String refreshToken;
}
@@ -0,0 +1,32 @@
package com.unicorn.hgzero.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Refresh Token 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenResponse {
/**
* 새로 발급된 Access Token
*/
private String accessToken;
/**
* 토큰 타입 (Bearer)
*/
@Builder.Default
private String tokenType = "Bearer";
/**
* Access Token 유효 기간 (초)
*/
private Long accessTokenValidity;
}
@@ -0,0 +1,51 @@
package com.unicorn.hgzero.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 토큰 검증 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenValidateResponse {
/**
* 토큰 유효 여부
*/
private Boolean valid;
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String username;
/**
* 이메일
*/
private String email;
/**
* 권한
*/
private String authority;
/**
* 토큰 만료 여부
*/
private Boolean expired;
/**
* 에러 메시지 (유효하지 않은 경우)
*/
private String errorMessage;
}
@@ -0,0 +1,141 @@
package com.unicorn.hgzero.user.repository.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.user.domain.User;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 사용자 Entity
* 사용자 인증 정보를 데이터베이스에 저장
*/
@Entity
@Table(name = "users")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity extends BaseTimeEntity {
/**
* 사용자 ID (Primary Key)
*/
@Id
@Column(name = "user_id", length = 50)
private String userId;
/**
* 사용자 이름
*/
@Column(name = "username", length = 100, nullable = false)
private String username;
/**
* 이메일
*/
@Column(name = "email", length = 100, nullable = false)
private String email;
/**
* 권한 (USER, ADMIN)
*/
@Column(name = "authority", length = 20, nullable = false)
@Builder.Default
private String authority = "USER";
/**
* 계정 잠금 여부
*/
@Column(name = "locked", nullable = false)
@Builder.Default
private Boolean locked = false;
/**
* 로그인 실패 횟수
*/
@Column(name = "failed_login_attempts", nullable = false)
@Builder.Default
private Integer failedLoginAttempts = 0;
/**
* 마지막 로그인 일시
*/
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
/**
* 계정 잠금 일시
*/
@Column(name = "locked_at")
private LocalDateTime lockedAt;
/**
* Entity를 Domain으로 변환
*
* @return User 도메인 객체
*/
public User toDomain() {
return User.builder()
.userId(this.userId)
.username(this.username)
.email(this.email)
.authority(this.authority)
.locked(this.locked)
.failedLoginAttempts(this.failedLoginAttempts)
.lastLoginAt(this.lastLoginAt)
.lockedAt(this.lockedAt)
.build();
}
/**
* Domain을 Entity로 변환
*
* @param user User 도메인 객체
* @return UserEntity
*/
public static UserEntity fromDomain(User user) {
return UserEntity.builder()
.userId(user.getUserId())
.username(user.getUsername())
.email(user.getEmail())
.authority(user.getAuthority())
.locked(user.getLocked())
.failedLoginAttempts(user.getFailedLoginAttempts())
.lastLoginAt(user.getLastLoginAt())
.lockedAt(user.getLockedAt())
.build();
}
/**
* 계정 잠금 해제
*/
public void unlock() {
this.locked = false;
this.failedLoginAttempts = 0;
this.lockedAt = null;
}
/**
* 로그인 실패 기록
*/
public void recordLoginFailure() {
this.failedLoginAttempts++;
if (this.failedLoginAttempts >= 5) {
this.locked = true;
this.lockedAt = LocalDateTime.now();
}
}
/**
* 로그인 성공 기록
*/
public void recordLoginSuccess() {
this.failedLoginAttempts = 0;
this.lastLoginAt = LocalDateTime.now();
}
}
@@ -0,0 +1,31 @@
package com.unicorn.hgzero.user.repository.jpa;
import com.unicorn.hgzero.user.repository.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 사용자 Repository
* 사용자 데이터 접근을 위한 JPA Repository
*/
@Repository
public interface UserRepository extends JpaRepository<UserEntity, String> {
/**
* 이메일로 사용자 조회
*
* @param email 이메일
* @return Optional<UserEntity>
*/
Optional<UserEntity> findByEmail(String email);
/**
* 사용자명으로 사용자 조회
*
* @param username 사용자명
* @return Optional<UserEntity>
*/
Optional<UserEntity> findByUsername(String username);
}
@@ -0,0 +1,45 @@
package com.unicorn.hgzero.user.service;
import com.unicorn.hgzero.user.dto.*;
/**
* 사용자 인증 서비스 인터페이스
*/
public interface UserService {
/**
* 사용자 로그인
* LDAP 인증 수행 후 JWT 토큰 발급
*
* @param request 로그인 요청 정보
* @return 로그인 응답 (Access Token, Refresh Token)
*/
LoginResponse login(LoginRequest request);
/**
* Access Token 갱신
* Refresh Token을 사용하여 새로운 Access Token 발급
*
* @param request Refresh Token 요청 정보
* @return 새로운 Access Token
*/
RefreshTokenResponse refresh(RefreshTokenRequest request);
/**
* 로그아웃
* Refresh Token 삭제
*
* @param request 로그아웃 요청 정보
* @param userId 사용자 ID
*/
void logout(LogoutRequest request, String userId);
/**
* Access Token 검증
* 토큰 유효성 검증 및 사용자 정보 반환
*
* @param token Access Token
* @return 토큰 검증 결과
*/
TokenValidateResponse validateToken(String token);
}
@@ -0,0 +1,321 @@
package com.unicorn.hgzero.user.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.user.config.jwt.JwtTokenProvider;
import com.unicorn.hgzero.user.domain.User;
import com.unicorn.hgzero.user.dto.*;
import com.unicorn.hgzero.user.repository.entity.UserEntity;
import com.unicorn.hgzero.user.repository.jpa.UserRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.Filter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.SecretKey;
import javax.naming.directory.DirContext;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import io.jsonwebtoken.security.Keys;
/**
* 사용자 인증 서비스 구현체
* LDAP 인증, JWT 토큰 발급, Refresh Token 관리 기능 제공
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final LdapTemplate ldapTemplate;
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.access-token-validity:3600}")
private Long accessTokenValidity;
@Value("${jwt.refresh-token-validity:604800}")
private Long refreshTokenValidity;
@Value("${spring.ldap.user-dn-pattern:uid={0},ou=people}")
private String userDnPattern;
/**
* 사용자 로그인
* LDAP 인증 수행 후 JWT 토큰 발급
*/
@Override
@Transactional
public LoginResponse login(LoginRequest request) {
log.info("로그인 시도: userId={}", request.getUserId());
// 사용자 조회 또는 생성
UserEntity userEntity = userRepository.findById(request.getUserId())
.orElse(null);
// 계정 잠금 확인
if (userEntity != null && userEntity.getLocked()) {
// 30분 경과 시 자동 해제
if (userEntity.getLockedAt() != null &&
LocalDateTime.now().isAfter(userEntity.getLockedAt().plusMinutes(30))) {
userEntity.unlock();
userRepository.save(userEntity);
} else {
log.warn("계정 잠금: userId={}", request.getUserId());
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
}
// LDAP 인증
try {
authenticateWithLdap(request.getUserId(), request.getPassword());
log.info("LDAP 인증 성공: userId={}", request.getUserId());
} catch (Exception e) {
log.error("LDAP 인증 실패: userId={}, error={}", request.getUserId(), e.getMessage());
// 로그인 실패 기록
if (userEntity != null) {
userEntity.recordLoginFailure();
userRepository.save(userEntity);
}
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, "인증에 실패했습니다.");
}
// 사용자 정보 조회 또는 생성
if (userEntity == null) {
// LDAP에서 추가 정보 조회 (실제 환경에서는 LDAP에서 이메일 등을 가져와야 함)
userEntity = UserEntity.builder()
.userId(request.getUserId())
.username(request.getUserId()) // LDAP에서 가져온 실제 이름 사용
.email(request.getUserId() + "@example.com") // LDAP에서 가져온 실제 이메일 사용
.authority("USER")
.locked(false)
.failedLoginAttempts(0)
.build();
}
// 로그인 성공 기록
userEntity.recordLoginSuccess();
userEntity = userRepository.save(userEntity);
User user = userEntity.toDomain();
// JWT 토큰 생성
String accessToken = generateAccessToken(user);
String refreshToken = generateRefreshToken(user);
// Refresh Token을 Redis에 저장
saveRefreshToken(user.getUserId(), refreshToken);
log.info("로그인 성공: userId={}", request.getUserId());
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.accessTokenValidity(accessTokenValidity)
.refreshTokenValidity(refreshTokenValidity)
.user(LoginResponse.UserInfo.builder()
.userId(user.getUserId())
.username(user.getUsername())
.email(user.getEmail())
.authority(user.getAuthority())
.build())
.build();
}
/**
* Access Token 갱신
*/
@Override
@Transactional(readOnly = true)
public RefreshTokenResponse refresh(RefreshTokenRequest request) {
log.info("토큰 갱신 시도");
// Refresh Token 검증
if (!jwtTokenProvider.validateToken(request.getRefreshToken())) {
log.warn("유효하지 않은 Refresh Token");
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
}
// Refresh Token에서 사용자 ID 추출
String userId = jwtTokenProvider.getUserId(request.getRefreshToken());
// Redis에서 저장된 Refresh Token 조회
String savedRefreshToken = getRefreshToken(userId);
if (savedRefreshToken == null || !savedRefreshToken.equals(request.getRefreshToken())) {
log.warn("Refresh Token이 일치하지 않음: userId={}", userId);
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
}
// 사용자 조회
UserEntity userEntity = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "사용자를 찾을 수 없습니다."));
User user = userEntity.toDomain();
// 새로운 Access Token 생성
String newAccessToken = generateAccessToken(user);
log.info("토큰 갱신 성공: userId={}", userId);
return RefreshTokenResponse.builder()
.accessToken(newAccessToken)
.tokenType("Bearer")
.accessTokenValidity(accessTokenValidity)
.build();
}
/**
* 로그아웃
*/
@Override
@Transactional
public void logout(LogoutRequest request, String userId) {
log.info("로그아웃: userId={}", userId);
// Redis에서 Refresh Token 삭제
deleteRefreshToken(userId);
log.info("로그아웃 완료: userId={}", userId);
}
/**
* Access Token 검증
*/
@Override
@Transactional(readOnly = true)
public TokenValidateResponse validateToken(String token) {
log.debug("토큰 검증 시작");
try {
// 토큰 유효성 검증
if (!jwtTokenProvider.validateToken(token)) {
return TokenValidateResponse.builder()
.valid(false)
.expired(jwtTokenProvider.isTokenExpired(token))
.errorMessage("유효하지 않은 토큰입니다.")
.build();
}
// 토큰에서 사용자 정보 추출
String userId = jwtTokenProvider.getUserId(token);
String username = jwtTokenProvider.getUsername(token);
String authority = jwtTokenProvider.getAuthority(token);
// 사용자 조회
UserEntity userEntity = userRepository.findById(userId)
.orElse(null);
return TokenValidateResponse.builder()
.valid(true)
.userId(userId)
.username(username)
.email(userEntity != null ? userEntity.getEmail() : null)
.authority(authority)
.expired(false)
.build();
} catch (Exception e) {
log.error("토큰 검증 실패: error={}", e.getMessage());
return TokenValidateResponse.builder()
.valid(false)
.expired(false)
.errorMessage("토큰 검증 중 오류가 발생했습니다.")
.build();
}
}
/**
* LDAP 인증 수행
*/
private void authenticateWithLdap(String userId, String password) {
// LDAP DN 생성
String userDn = userDnPattern.replace("{0}", userId);
// LDAP 인증
Filter filter = new EqualsFilter("uid", userId);
boolean authenticated = ldapTemplate.authenticate("", filter.encode(), password);
if (!authenticated) {
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
}
}
/**
* Access Token 생성
*/
private String generateAccessToken(User user) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidity * 1000);
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.setSubject(user.getUserId())
.claim("username", user.getUsername())
.claim("authority", user.getAuthority())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key)
.compact();
}
/**
* Refresh Token 생성
*/
private String generateRefreshToken(User user) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidity * 1000);
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.setSubject(user.getUserId())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key)
.compact();
}
/**
* Refresh Token을 Redis에 저장
*/
private void saveRefreshToken(String userId, String refreshToken) {
String key = "refresh_token:" + userId;
redisTemplate.opsForValue().set(key, refreshToken, refreshTokenValidity, TimeUnit.SECONDS);
log.debug("Refresh Token 저장: userId={}", userId);
}
/**
* Redis에서 Refresh Token 조회
*/
private String getRefreshToken(String userId) {
String key = "refresh_token:" + userId;
return redisTemplate.opsForValue().get(key);
}
/**
* Redis에서 Refresh Token 삭제
*/
private void deleteRefreshToken(String userId) {
String key = "refresh_token:" + userId;
redisTemplate.delete(key);
log.debug("Refresh Token 삭제: userId={}", userId);
}
}
+105
View File
@@ -0,0 +1,105 @@
spring:
application:
name: user
# Database Configuration
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:20.214.121.121}:${DB_PORT:5432}/${DB_NAME:userdb}
username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA Configuration
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis Configuration
data:
redis:
host: ${REDIS_HOST:20.249.177.114}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:0}
# LDAP Configuration
ldap:
urls: ${LDAP_URLS:ldaps://ldap.example.com:636}
base: ${LDAP_BASE:dc=example,dc=com}
username: ${LDAP_USERNAME:}
password: ${LDAP_PASSWORD:}
user-dn-pattern: ${LDAP_USER_DN_PATTERN:uid={0},ou=people}
# Server Configuration
server:
port: ${SERVER_PORT:8080}
# JWT Configuration
jwt:
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# Actuator Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: always
show-components: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
# Logging Configuration
logging:
level:
com.unicorn.hgzero.user: ${LOG_LEVEL_APP:DEBUG}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE_PATH:logs/user.log}